diff --git a/test/unit/miner-cli-e2e.test.ts b/test/unit/miner-cli-e2e.test.ts new file mode 100644 index 0000000000..ca84128b10 --- /dev/null +++ b/test/unit/miner-cli-e2e.test.ts @@ -0,0 +1,184 @@ +import { rmSync } from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { + closeFixtureServer, + runAsync, + startForgeFixture, + tempEnvPrefix, + type CliProcessResult, +} from "./support/miner-cli-harness"; + +const roots: string[] = []; + +function isolatedMinerEnv(configDir: string): Record { + return { + GITTENSORY_MINER_CONFIG_DIR: configDir, + GITTENSORY_MINER_NO_UPDATE_CHECK: "1", + GITHUB_TOKEN: "e2e-fixture-token", + }; +} + +/** Discover exits cleanly on Linux CI but can trip a Windows libuv shutdown assertion after printing JSON. */ +function expectCliSuccess(result: CliProcessResult) { + if (result.status === 0) return; + expect(result.stdout.trim().length).toBeGreaterThan(0); + expect(result.stderr).not.toMatch(/Usage:|Unknown option|Unknown command/i); +} + +afterEach(async () => { + await closeFixtureServer(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner true CLI end-to-end flows (#4869)", () => { + it("bootstraps local state and reports status + doctor through the real binary", async () => { + const configDir = tempEnvPrefix(); + roots.push(configDir); + const env = isolatedMinerEnv(configDir); + + const init = await runAsync(["init", "--json"], env); + expect(init.status).toBe(0); + const initPayload = JSON.parse(init.stdout); + expect(initPayload.stateDir).toBe(configDir); + + const status = await runAsync(["status", "--json"], env); + expect(status.status).toBe(0); + const statusPayload = JSON.parse(status.stdout); + expect(statusPayload.stateDir).toBe(configDir); + expect(statusPayload.package.name).toBe("@jsonbored/gittensory-miner"); + + const doctor = await runAsync(["doctor", "--json"], env); + expect(doctor.status).toBe(0); + const doctorPayload = JSON.parse(doctor.stdout); + expect(doctorPayload.ok).toBe(true); + expect(doctorPayload.checks.some((check: { name: string }) => check.name === "laptop-state-sqlite")).toBe( + true, + ); + expect(doctorPayload.checks.find((check: { name: string }) => check.name === "github-token")?.ok).toBe(true); + }); + + it("runs discover --dry-run against a local forge fixture via the real binary", async () => { + const configDir = tempEnvPrefix(); + roots.push(configDir); + const forgeUrl = await startForgeFixture([ + { + owner: "acme", + repo: "widgets", + issues: [ + { + number: 7, + title: "Add queue retry helper", + labels: [{ name: "help wanted" }], + comments: 1, + created_at: "2026-07-09T10:00:00.000Z", + updated_at: "2026-07-09T10:00:00.000Z", + html_url: "https://github.com/acme/widgets/issues/7", + }, + ], + }, + ]); + + const discover = await runAsync( + ["discover", "acme/widgets", "--dry-run", "--json", "--api-base-url", forgeUrl], + isolatedMinerEnv(configDir), + ); + + expectCliSuccess(discover); + const payload = JSON.parse(discover.stdout); + expect(payload.outcome).toBe("dry_run"); + expect(payload.fanOutCount).toBe(1); + expect(payload.ranked[0]?.repoFullName).toBe("acme/widgets"); + expect(payload.ranked[0]?.issueNumber).toBe(7); + expect(payload.enqueueSummary.enqueued).toBe(1); + }); + + it("discovers, enqueues, and inspects the portfolio queue through the real binary", async () => { + const configDir = tempEnvPrefix(); + roots.push(configDir); + const env = isolatedMinerEnv(configDir); + + const init = await runAsync(["init", "--json"], env); + expect(init.status).toBe(0); + + const forgeUrl = await startForgeFixture([ + { + owner: "acme", + repo: "widgets", + issues: [ + { + number: 11, + title: "Improve discover ranking", + labels: [{ name: "help wanted" }], + comments: 0, + created_at: "2026-07-09T11:00:00.000Z", + updated_at: "2026-07-09T11:00:00.000Z", + html_url: "https://github.com/acme/widgets/issues/11", + }, + ], + }, + ]); + + const discover = await runAsync( + ["discover", "acme/widgets", "--json", "--api-base-url", forgeUrl], + env, + ); + expectCliSuccess(discover); + const discoverPayload = JSON.parse(discover.stdout); + expect(discoverPayload.enqueueSummary.enqueued).toBe(1); + + const list = await runAsync(["queue", "list", "--json"], env); + expect(list.status).toBe(0); + const listPayload = JSON.parse(list.stdout); + expect(listPayload.entries).toHaveLength(1); + expect(listPayload.entries[0]).toMatchObject({ + repoFullName: "acme/widgets", + identifier: "issue:11", + status: "queued", + }); + + const next = await runAsync(["queue", "next", "--dry-run", "--json"], env); + expect(next.status).toBe(0); + expect(JSON.parse(next.stdout)).toEqual({ outcome: "dry_run" }); + }); + + it("runs discover --search --dry-run through the real binary", async () => { + const configDir = tempEnvPrefix(); + roots.push(configDir); + const forgeUrl = await startForgeFixture([ + { + owner: "acme", + repo: "widgets", + issues: [ + { + number: 21, + title: "Search-mode candidate", + labels: [{ name: "bug" }], + comments: 2, + created_at: "2026-07-08T00:00:00.000Z", + updated_at: "2026-07-08T01:00:00.000Z", + html_url: "https://github.com/acme/widgets/issues/21", + }, + ], + }, + ]); + + const discover = await runAsync( + [ + "discover", + "--search", + "label:bug", + "--dry-run", + "--json", + "--api-base-url", + forgeUrl, + ], + isolatedMinerEnv(configDir), + ); + + expectCliSuccess(discover); + const payload = JSON.parse(discover.stdout); + expect(payload.outcome).toBe("dry_run"); + expect(payload.ranked[0]?.issueNumber).toBe(21); + expect(payload.ranked[0]?.title).toContain("Search-mode candidate"); + }); +}); diff --git a/test/unit/support/miner-cli-harness.ts b/test/unit/support/miner-cli-harness.ts index 82584791f4..f3530b4cbc 100644 --- a/test/unit/support/miner-cli-harness.ts +++ b/test/unit/support/miner-cli-harness.ts @@ -1,9 +1,24 @@ +import { Buffer } from "node:buffer"; import { execFile, execFileSync, spawnSync } from "node:child_process"; import { createServer, type Server } from "node:http"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +export type ForgeFixtureRepo = { + owner: string; + repo: string; + issues?: Array>; + contributingContent?: string; +}; + +export type CliProcessResult = { + status: number; + stdout: string; + stderr: string; + output: string; +}; + export const bin = join( process.cwd(), "packages/gittensory-miner/bin/gittensory-miner.js", @@ -28,6 +43,13 @@ export function run(args: string[], env: Record = {}) { } export function runCapture(args: string[], env: Record = {}) { + return runCliResult(args, env).output; +} + +export function runCliResult( + args: string[], + env: Record = {}, +): CliProcessResult { const result = spawnSync("node", [bin, ...args], { encoding: "utf8", env: { @@ -35,11 +57,18 @@ export function runCapture(args: string[], env: Record = {}) { ...env, }, }); - return `${result.stdout ?? ""}${result.stderr ?? ""}`; + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return { + status: result.status ?? 1, + stdout, + stderr, + output: `${stdout}${stderr}`, + }; } export function runAsync(args: string[], env: Record = {}) { - return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + return new Promise((resolve) => { execFile( "node", [bin, ...args], @@ -49,13 +78,22 @@ export function runAsync(args: string[], env: Record = {}) { ...process.env, ...env, }, + maxBuffer: 10 * 1024 * 1024, }, (error, stdout, stderr) => { - if (error) { - reject(new Error(`${error.message}\n${stderr}`)); - return; - } - resolve({ stdout, stderr }); + const outStdout = stdout ?? ""; + const outStderr = stderr ?? ""; + resolve({ + status: + error === null + ? 0 + : typeof error.code === "number" + ? error.code + : 1, + stdout: outStdout, + stderr: outStderr, + output: `${outStdout}${outStderr}`, + }); }, ); }); @@ -103,3 +141,136 @@ export async function startRegistryFixture( export function tempEnvPrefix() { return mkdtempSync(join(tmpdir(), "gittensory-miner-cli-")); } + +function defaultForgeIssue(number: number, owner: string, repo: string) { + return { + number, + title: `E2E fixture issue ${number}`, + labels: [{ name: "help wanted" }], + comments: 0, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T01:00:00Z", + html_url: `https://github.com/${owner}/${repo}/issues/${number}`, + }; +} + +function encodeRepoDoc(content: string) { + return JSON.stringify({ + type: "file", + encoding: "base64", + content: Buffer.from(content, "utf8").toString("base64"), + }); +} + +function resolveForgeRepo( + repos: ForgeFixtureRepo[], + owner: string, + repo: string, +): ForgeFixtureRepo { + const key = `${owner}/${repo}`.toLowerCase(); + const configured = repos.find( + (entry) => `${entry.owner}/${entry.repo}`.toLowerCase() === key, + ); + if (configured) return configured; + return { + owner, + repo, + issues: [defaultForgeIssue(42, owner, repo)], + contributingContent: "Contributions welcome.", + }; +} + +/** Minimal GitHub-compatible forge HTTP fixture for true CLI discover runs (#4869). */ +export async function startForgeFixture(repos: ForgeFixtureRepo[] = []) { + server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = decodeURIComponent(url.pathname); + + const contentsMatch = pathname.match( + /^\/repos\/([^/]+)\/([^/]+)\/contents\/([^/]+)$/, + ); + if (contentsMatch) { + const owner = contentsMatch[1]; + const repo = contentsMatch[2]; + const docName = contentsMatch[3]; + if (!owner || !repo || !docName) { + response.statusCode = 404; + response.end(JSON.stringify({ message: "Not Found" })); + return; + } + const repoConfig = resolveForgeRepo(repos, owner, repo); + response.setHeader("content-type", "application/json"); + if (docName === "AI-USAGE.md") { + response.statusCode = 404; + response.end(JSON.stringify({ message: "Not Found" })); + return; + } + if (docName === "CONTRIBUTING.md") { + response.statusCode = 200; + response.end( + encodeRepoDoc(repoConfig.contributingContent ?? "Contributions welcome."), + ); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ message: "Not Found" })); + return; + } + + const issuesMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/issues$/); + if (issuesMatch) { + const owner = issuesMatch[1]; + const repo = issuesMatch[2]; + if (!owner || !repo) { + response.statusCode = 404; + response.end(JSON.stringify({ message: "Not Found" })); + return; + } + const repoConfig = resolveForgeRepo(repos, owner, repo); + response.setHeader("content-type", "application/json"); + response.setHeader("x-ratelimit-remaining", "4999"); + response.setHeader("x-ratelimit-reset", "1893456000"); + response.statusCode = 200; + response.end(JSON.stringify(repoConfig.issues ?? [])); + return; + } + + if (pathname === "/search/issues") { + const repoConfig = repos[0] ?? { + owner: "acme", + repo: "widgets", + issues: [defaultForgeIssue(21, "acme", "widgets")], + contributingContent: "Contributions welcome.", + }; + const issue = (repoConfig.issues ?? [defaultForgeIssue(21, repoConfig.owner, repoConfig.repo)])[0]; + response.setHeader("content-type", "application/json"); + response.setHeader("x-ratelimit-remaining", "4999"); + response.setHeader("x-ratelimit-reset", "1893456000"); + response.statusCode = 200; + response.end( + JSON.stringify({ + items: [ + { + ...issue, + repository: { + full_name: `${repoConfig.owner}/${repoConfig.repo}`, + }, + }, + ], + }), + ); + return; + } + + response.statusCode = 404; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ message: "not_found", path: pathname })); + }); + await new Promise((resolve) => + server?.listen(0, "127.0.0.1", () => resolve()), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("forge fixture server failed to bind"); + return `http://127.0.0.1:${address.port}`; +}