feat: scaffold CLI and minimal issue list (task 0001)
Tracer bullet for gitea-axi: runnable npm package on axi-sdk-js with gitea-js as the sole HTTP layer, ESM on Node 20+. - issue list with --state/--limit, default fields, count line from X-Total-Count, type=issues guard, explicit empty state, and next-step suggestions - repo context detection from the git origin remote (SSH/scp/HTTPS), tea credential discovery with the three-way login-matching split, and -R/--repo and --login overrides (flag > env > auto) - token retrieval via tea login helper get: tea's login list JSON carries no token (ADR 0001 amended) - full AxiError classification table with path-based 404 split, TOON errors on stdout, exit codes 0/1/2 - test mode (GITEA_AXI_API_URL/TOKEN/REPO) suppressing subprocesses, fixture server, and vitest suites driving the CLI seam (50 tests)
This commit is contained in:
257
test/detection.test.ts
Normal file
257
test/detection.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest } from "./harness.js";
|
||||
|
||||
interface FakeLogin {
|
||||
name: string;
|
||||
url: string;
|
||||
ssh_host?: string;
|
||||
user?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "gitea-axi-detect-"));
|
||||
const gitPath = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
||||
// The fake tea script needs cat on the sandbox PATH for its heredoc branches.
|
||||
const catPath = execFileSync("which", ["cat"], { encoding: "utf8" }).trim();
|
||||
let sandboxCounter = 0;
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
|
||||
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
|
||||
const bin = join(root, `bin-${sandboxCounter++}`);
|
||||
mkdirSync(bin);
|
||||
symlinkSync(gitPath, join(bin, "git"));
|
||||
symlinkSync(catPath, join(bin, "cat"));
|
||||
if (options.tea !== false) {
|
||||
const script = `#!/bin/sh
|
||||
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
||||
cat <<'JSON'
|
||||
${JSON.stringify(options.logins ?? [])}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
||||
cat > /dev/null
|
||||
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected tea invocation: $*" >&2
|
||||
exit 1
|
||||
`;
|
||||
writeFileSync(join(bin, "tea"), script, { mode: 0o755 });
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
function makeRepo(remoteUrl: string | undefined): string {
|
||||
const dir = join(root, `repo-${sandboxCounter++}`);
|
||||
mkdirSync(dir);
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: dir });
|
||||
if (remoteUrl) {
|
||||
execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||
|
||||
let server: FixtureServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
async function startIssuesServer(): Promise<FixtureServer> {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("repository context detection", () => {
|
||||
it("detects the repo from an HTTPS origin remote and authenticates via tea", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||
token: "detected-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 3 of 3 total");
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer detected-token");
|
||||
// Auto-detected context: suggestions must not carry override flags.
|
||||
expect(stdout).not.toContain("-R testowner/testrepo");
|
||||
expect(stdout).not.toContain("--login");
|
||||
});
|
||||
|
||||
it("detects the repo from an SSH (scp-form) origin remote", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo("git@127.0.0.1:testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||
token: "detected-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 3 of 3 total");
|
||||
});
|
||||
|
||||
it("fails with REPO_NOT_FOUND when there is no recognizable origin remote", async () => {
|
||||
const cwd = makeRepo(undefined);
|
||||
const bin = makeSandbox({ logins: [] });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ tea: false });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: TEA_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
it("fails with AUTH_REQUIRED when tea has zero logins", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ logins: [] });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||
expect(stdout).toContain("tea login add");
|
||||
});
|
||||
|
||||
it("fails with REPO_NOT_FOUND when no login matches the remote hostname", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "other", url: "https://other.example.net", default: "true" }],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
expect(stdout).toContain("gitea.example.com");
|
||||
});
|
||||
|
||||
it("fails with VALIDATION_ERROR listing profiles on an ambiguous multi-match", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url: "https://gitea.example.com" },
|
||||
{ name: "personal", url: "https://gitea.example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("work");
|
||||
expect(stdout).toContain("personal");
|
||||
});
|
||||
|
||||
it("uses tea's default login when several match the hostname", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url },
|
||||
{ name: "personal", url, default: "true" },
|
||||
],
|
||||
token: "default-token",
|
||||
});
|
||||
|
||||
const { exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer default-token");
|
||||
});
|
||||
|
||||
it("fails with VALIDATION_ERROR listing available profiles for a nonexistent --login", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url: "https://gitea.example.com", default: "true" },
|
||||
{ name: "personal", url: "https://gitea.example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--login", "missing"],
|
||||
{ env: { PATH: bin }, cwd },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("missing");
|
||||
expect(stdout).toContain("available: work, personal");
|
||||
});
|
||||
|
||||
it("selects a login by name with --login and adds it to suggestions", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo("https://unrelated.example.org/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url }],
|
||||
token: "named-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["--login", "fixture", "issue", "list"],
|
||||
{ env: { PATH: bin }, cwd },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
||||
expect(stdout).toContain("--login fixture");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user