test: split the packaging tier and parameterize its installed binary (task 0036)
The packaging tier held two kinds of assertion joined only by an expensive shared setup: the shape of the packed tarball and its manifest, and the behaviour of the resulting installed binary. Split them, and teach the second to take the binary it drives from GITEA_AXI_INSTALLED_BIN. When that variable names an existing binary the tier drives it and skips pack-and-install entirely; unset, it packs and installs exactly as before. The installed-binary facet becomes one seam with two callers — the npm distribution path today, the Nix installation path in task 0038 — so the two cannot drift apart in what they guarantee about an installed gitea-axi. Nothing in it may assert on how the binary came to exist, since store paths, wrapper internals, and the arrangement of the installed tree are implementation detail of the installation method. The tarball assertions stay npm-only: no other distribution method produces a tarball or a packed manifest.
This commit was merged in pull request #45.
This commit is contained in:
118
test/packaging/installed-binary.test.ts
Normal file
118
test/packaging/installed-binary.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer } from "../fixture-server.js";
|
||||
import { installGlobally, packTarball } from "./npm-artifact.js";
|
||||
|
||||
// What an *installed* gitea-axi must do, whatever installed it: run, render
|
||||
// against a Gitea, and install its Agent Skill.
|
||||
//
|
||||
// The binary under test comes from the environment when GITEA_AXI_INSTALLED_BIN
|
||||
// names one, and otherwise from packing and globally installing the npm tarball
|
||||
// right here. That makes this one seam with two callers — the npm distribution
|
||||
// path and the Nix installation path — so the two cannot drift apart in what
|
||||
// they guarantee.
|
||||
//
|
||||
// Consequently nothing below may assert on how the binary came to exist: no
|
||||
// store paths, no wrapper internals, no arrangement of files within the
|
||||
// installed tree. Those are implementation detail of the installation method.
|
||||
|
||||
/**
|
||||
* Set by a caller that has already installed gitea-axi and wants that one
|
||||
* driven. An empty value counts as unset, so exporting it blank is the same as
|
||||
* not exporting it at all.
|
||||
*/
|
||||
const providedBin = process.env.GITEA_AXI_INSTALLED_BIN || undefined;
|
||||
|
||||
let workDir: string | undefined;
|
||||
let binPath: string;
|
||||
|
||||
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
|
||||
// environment fully explicit so nothing leaks in, this tier spawns a real
|
||||
// installed binary as a subprocess. That subprocess genuinely needs the parent
|
||||
// env (PATH to resolve node/git/tea, npm config, etc.), so both helpers inherit
|
||||
// `process.env` on purpose and layer the per-call `env` on top.
|
||||
|
||||
/** Run the installed binary, returning its stdout. */
|
||||
function run(args: string[], env: Record<string, string> = {}): string {
|
||||
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Run the installed binary without blocking this process's event loop, so an
|
||||
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
|
||||
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
|
||||
* deadlocking the request/response.)
|
||||
*/
|
||||
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
|
||||
const { stdout } = await execFileAsync(binPath, args, {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (providedBin !== undefined) {
|
||||
// Fail here rather than letting every assertion fail on a confusing ENOENT
|
||||
// from the spawn.
|
||||
if (!existsSync(providedBin)) {
|
||||
throw new Error(
|
||||
`GITEA_AXI_INSTALLED_BIN points at ${providedBin}, which does not exist. ` +
|
||||
"Unset it to have this tier pack and install the npm tarball itself.",
|
||||
);
|
||||
}
|
||||
binPath = providedBin;
|
||||
return;
|
||||
}
|
||||
|
||||
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-install-"));
|
||||
binPath = installGlobally(packTarball(workDir), workDir);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (workDir) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("installed gitea-axi binary", () => {
|
||||
it("is executable and prints its usage", () => {
|
||||
expect(existsSync(binPath)).toBe(true);
|
||||
expect(run(["--help"])).toContain("usage: gitea-axi");
|
||||
});
|
||||
|
||||
it("renders the dashboard header against a Gitea", async () => {
|
||||
const server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
|
||||
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
|
||||
]);
|
||||
try {
|
||||
const dashboard = await runAsync([], {
|
||||
GITEA_AXI_API_URL: server.url,
|
||||
GITEA_AXI_REPO: "o/r",
|
||||
GITEA_AXI_TOKEN: "x",
|
||||
});
|
||||
expect(dashboard).toContain("bin:");
|
||||
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
|
||||
expect(dashboard).toContain("repo: o/r");
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("finds its bundled Agent Skill and installs it into HOME/.claude", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
|
||||
try {
|
||||
expect(run(["setup"], { HOME: home })).toContain("status: installed");
|
||||
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
53
test/packaging/npm-artifact.ts
Normal file
53
test/packaging/npm-artifact.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Building the npm distribution artifact, factored out so the two packaging
|
||||
// facets can each take just the part they need: the tarball facet packs and
|
||||
// extracts, the installed-binary facet packs and installs.
|
||||
|
||||
export const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
/**
|
||||
* Pack the real tarball into `destDir`, returning its path.
|
||||
*
|
||||
* `npm pack --json` reports the tarball filename; the shape shifted across npm
|
||||
* majors (an array of entries on npm <12, an object keyed by package name on
|
||||
* npm 12+), so accept either and pull the one filename out.
|
||||
*/
|
||||
export function packTarball(destDir: string): string {
|
||||
const packOutput = execFileSync("npm", ["pack", "--json", "--pack-destination", destDir], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const packResult = JSON.parse(packOutput) as unknown;
|
||||
const packEntries = Array.isArray(packResult)
|
||||
? (packResult as Array<{ filename: string }>)
|
||||
: Object.values(packResult as Record<string, { filename: string }>);
|
||||
return join(destDir, packEntries[0]!.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `tarball` into a fresh `extract/` under `destDir`, returning the
|
||||
* directory npm nests everything under (`<destDir>/extract/package`).
|
||||
*/
|
||||
export function extractTarball(tarball: string, destDir: string): string {
|
||||
const extractDir = join(destDir, "extract");
|
||||
mkdirSync(extractDir);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
|
||||
return join(extractDir, "package");
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `tarball` globally into a throwaway prefix under `destDir`, returning
|
||||
* the path of the installed binary on that prefix's bin dir.
|
||||
*/
|
||||
export function installGlobally(tarball: string, destDir: string): string {
|
||||
const prefix = join(destDir, "prefix");
|
||||
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return join(prefix, "bin", "gitea-axi");
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer } from "../fixture-server.js";
|
||||
|
||||
// The one artifact under test: the packed npm tarball, installed globally into a
|
||||
// throwaway prefix. Packing plus a global install is expensive and identical for
|
||||
// every facet, so it is built once here and the four facets assert against the
|
||||
// shared result.
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
let workDir: string;
|
||||
let extractDir: string;
|
||||
let binPath: string;
|
||||
/** The manifest as it ships inside the packed tarball. */
|
||||
let packedManifest: Record<string, unknown>;
|
||||
|
||||
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
|
||||
// environment fully explicit so nothing leaks in, this tier spawns a real
|
||||
// globally-installed binary as a subprocess. That subprocess genuinely needs the
|
||||
// parent env (PATH to resolve node/npm/tar, npm config, etc.), so both helpers
|
||||
// inherit `process.env` on purpose and layer the per-call `env` on top.
|
||||
|
||||
/** Run the globally installed binary, returning its stdout. */
|
||||
function run(args: string[], env: Record<string, string> = {}): string {
|
||||
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Run the installed binary without blocking this process's event loop, so an
|
||||
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
|
||||
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
|
||||
* deadlocking the request/response.)
|
||||
*/
|
||||
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
|
||||
const { stdout } = await execFileAsync(binPath, args, {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
|
||||
|
||||
// Pack the real tarball. `npm pack --json` reports the tarball filename; the
|
||||
// shape shifted across npm majors (an array of entries on npm <12, an object
|
||||
// keyed by package name on npm 12+), so accept either and pull the one
|
||||
// filename out.
|
||||
const packOutput = execFileSync(
|
||||
"npm",
|
||||
["pack", "--json", "--pack-destination", workDir],
|
||||
{ cwd: projectRoot, encoding: "utf8" },
|
||||
);
|
||||
const packResult = JSON.parse(packOutput) as unknown;
|
||||
const packEntries = Array.isArray(packResult)
|
||||
? (packResult as Array<{ filename: string }>)
|
||||
: Object.values(packResult as Record<string, { filename: string }>);
|
||||
const tarball = join(workDir, packEntries[0]!.filename);
|
||||
|
||||
// Extract to inspect the packed manifest and confirm bundled files. npm nests
|
||||
// everything under `package/`.
|
||||
extractDir = join(workDir, "extract");
|
||||
mkdirSync(extractDir);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
|
||||
packedManifest = JSON.parse(
|
||||
readFileSync(join(extractDir, "package", "package.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// Install globally into a throwaway prefix so the binary lands on a PATH-like
|
||||
// bin dir we control.
|
||||
const prefix = join(workDir, "prefix");
|
||||
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
binPath = join(prefix, "bin", "gitea-axi");
|
||||
}, 300_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (workDir) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("npm distribution artifact", () => {
|
||||
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
|
||||
expect(existsSync(join(extractDir, "package", "dist", "main.js"))).toBe(true);
|
||||
expect(existsSync(join(extractDir, "package", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||
|
||||
const bin = packedManifest.bin as Record<string, string> | undefined;
|
||||
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
|
||||
|
||||
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||
expect(scripts?.postinstall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes the bench/ harness directory from the package", () => {
|
||||
expect(existsSync(join(extractDir, "package", "bench"))).toBe(false);
|
||||
});
|
||||
|
||||
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
|
||||
const name = packedManifest.name as string;
|
||||
expect(name).toBe("gitea-axi");
|
||||
expect(name).not.toContain("@");
|
||||
expect(name).not.toContain("/");
|
||||
|
||||
const description = packedManifest.description as string;
|
||||
expect(typeof description).toBe("string");
|
||||
expect(description.length).toBeGreaterThan(0);
|
||||
|
||||
const repository = packedManifest.repository as string | { url?: string } | undefined;
|
||||
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
|
||||
expect(repositoryUrl).toContain("gitea-axi");
|
||||
|
||||
expect(packedManifest.license).toBe("MIT");
|
||||
|
||||
const engines = packedManifest.engines as Record<string, string> | undefined;
|
||||
expect(engines?.node).toMatch(/20/);
|
||||
|
||||
expect(packedManifest.type).toBe("module");
|
||||
});
|
||||
|
||||
it("scripts and documents the publish flow so publishing is a single command", () => {
|
||||
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||
expect(scripts?.prepack).toBe("npm run build");
|
||||
|
||||
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
|
||||
expect(publishConfig?.access).toBe("public");
|
||||
|
||||
const publishingDoc = join(projectRoot, "PUBLISHING.md");
|
||||
expect(existsSync(publishingDoc)).toBe(true);
|
||||
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
|
||||
});
|
||||
|
||||
it("puts a working gitea-axi on the PATH: dashboard header, --help, and setup all function", async () => {
|
||||
expect(existsSync(binPath)).toBe(true);
|
||||
|
||||
expect(run(["--help"])).toContain("usage: gitea-axi");
|
||||
|
||||
// Dashboard header renders against a stubbed Gitea.
|
||||
const server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
|
||||
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
|
||||
]);
|
||||
try {
|
||||
const dashboard = await runAsync([], {
|
||||
GITEA_AXI_API_URL: server.url,
|
||||
GITEA_AXI_REPO: "o/r",
|
||||
GITEA_AXI_TOKEN: "x",
|
||||
});
|
||||
expect(dashboard).toContain("bin:");
|
||||
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
|
||||
expect(dashboard).toContain("repo: o/r");
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
// `setup` installs the Agent Skill into HOME/.claude.
|
||||
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
|
||||
try {
|
||||
expect(run(["setup"], { HOME: home })).toContain("status: installed");
|
||||
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
83
test/packaging/tarball.test.ts
Normal file
83
test/packaging/tarball.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { extractTarball, packTarball, projectRoot } from "./npm-artifact.js";
|
||||
|
||||
// The shape of the packed npm tarball and the manifest that ships inside it.
|
||||
// These assertions are npm-specific by nature — no other distribution method
|
||||
// produces a tarball or a packed manifest — so they stay separate from the
|
||||
// installed-binary assertions, which any installation method can drive.
|
||||
|
||||
let workDir: string;
|
||||
/** The extracted tarball's root — npm nests everything under `package/`. */
|
||||
let packageDir: string;
|
||||
/** The manifest as it ships inside the packed tarball. */
|
||||
let packedManifest: Record<string, unknown>;
|
||||
|
||||
beforeAll(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
|
||||
const tarball = packTarball(workDir);
|
||||
packageDir = extractTarball(tarball, workDir);
|
||||
packedManifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}, 300_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (workDir) {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("npm distribution artifact", () => {
|
||||
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
|
||||
expect(existsSync(join(packageDir, "dist", "main.js"))).toBe(true);
|
||||
expect(existsSync(join(packageDir, "skills", "gitea-axi", "SKILL.md"))).toBe(true);
|
||||
|
||||
const bin = packedManifest.bin as Record<string, string> | undefined;
|
||||
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
|
||||
|
||||
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||
expect(scripts?.postinstall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes the bench/ harness directory from the package", () => {
|
||||
expect(existsSync(join(packageDir, "bench"))).toBe(false);
|
||||
});
|
||||
|
||||
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
|
||||
const name = packedManifest.name as string;
|
||||
expect(name).toBe("gitea-axi");
|
||||
expect(name).not.toContain("@");
|
||||
expect(name).not.toContain("/");
|
||||
|
||||
const description = packedManifest.description as string;
|
||||
expect(typeof description).toBe("string");
|
||||
expect(description.length).toBeGreaterThan(0);
|
||||
|
||||
const repository = packedManifest.repository as string | { url?: string } | undefined;
|
||||
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
|
||||
expect(repositoryUrl).toContain("gitea-axi");
|
||||
|
||||
expect(packedManifest.license).toBe("MIT");
|
||||
|
||||
const engines = packedManifest.engines as Record<string, string> | undefined;
|
||||
expect(engines?.node).toMatch(/20/);
|
||||
|
||||
expect(packedManifest.type).toBe("module");
|
||||
});
|
||||
|
||||
it("scripts and documents the publish flow so publishing is a single command", () => {
|
||||
const scripts = packedManifest.scripts as Record<string, string> | undefined;
|
||||
expect(scripts?.prepack).toBe("npm run build");
|
||||
|
||||
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
|
||||
expect(publishConfig?.access).toBe("public");
|
||||
|
||||
const publishingDoc = join(projectRoot, "PUBLISHING.md");
|
||||
expect(existsSync(publishingDoc)).toBe(true);
|
||||
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user