Files
paseo/packages/server/src/services/forge-cli-command.test.ts
T
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00

179 lines
5.3 KiB
TypeScript

import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createCachedCliPathResolver,
createForgeCliRunner,
probeHostViaCliAuthStatus,
} from "./forge-cli-command.js";
import { isPlatform } from "../test-utils/platform.js";
describe.skipIf(isPlatform("win32"))("probeHostViaCliAuthStatus", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-command-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeFakeCli(script: string): string {
const cliPath = join(tempDir, "forge-cli");
writeFileSync(cliPath, script);
chmodSync(cliPath, 0o755);
return cliPath;
}
it("recognizes a host when the CLI auth status succeeds", async () => {
const cli = writeFakeCli(`#!/bin/sh
if [ "$1" = "--version" ]; then
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--hostname" ] && [ "$4" = "git.acme.internal" ]; then
exit 0
fi
exit 1
`);
await expect(
probeHostViaCliAuthStatus({
cli,
host: "git.acme.internal",
}),
).resolves.toBe(true);
});
it("rejects a host when the CLI auth status fails", async () => {
const cli = writeFakeCli(`#!/bin/sh
if [ "$1" = "--version" ]; then
exit 0
fi
exit 1
`);
await expect(
probeHostViaCliAuthStatus({
cli,
host: "git.acme.internal",
}),
).resolves.toBe(false);
});
it("rejects a host when the CLI is missing", async () => {
await expect(
probeHostViaCliAuthStatus({
cli: join(tempDir, "missing-cli"),
host: "git.acme.internal",
}),
).resolves.toBe(false);
});
});
describe.skipIf(isPlatform("win32"))("createForgeCliRunner", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "forge-cli-runner-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeSlowCli(): string {
const cliPath = join(tempDir, "slow-cli");
writeFileSync(cliPath, "#!/bin/sh\nsleep 5\n");
chmodSync(cliPath, 0o755);
return cliPath;
}
class FakeCommandError extends Error {
readonly stderr: string;
constructor(params: { stderr: string }) {
super("command failed");
this.stderr = params.stderr;
}
}
it("kills a hung CLI invocation after the configured timeout", async () => {
const cli = writeSlowCli();
const runner = createForgeCliRunner({
binary: cli,
envOverlay: {},
timeoutMs: 100,
isAuthFailureText: () => false,
errorClasses: {
isAlreadyClassified: () => false,
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
createMissingError: () => new Error("missing"),
createCommandError: (params) => new FakeCommandError(params),
},
});
await expect(runner.run(["arg"], { cwd: tempDir })).rejects.toThrow();
});
it("classifies a timed-out invocation through normalizeError", async () => {
const cli = writeSlowCli();
const runner = createForgeCliRunner({
binary: cli,
envOverlay: {},
timeoutMs: 100,
isAuthFailureText: () => false,
errorClasses: {
isAlreadyClassified: () => false,
isCommandError: (error): error is FakeCommandError => error instanceof FakeCommandError,
createAuthError: (stderr) => new Error(`auth: ${stderr}`),
createMissingError: () => new Error("missing"),
createCommandError: (params) => new FakeCommandError(params),
},
});
const rawError = await runner.run(["arg"], { cwd: tempDir }).catch((error: unknown) => error);
const normalized = runner.normalizeError(rawError, { args: ["arg"], cwd: tempDir });
expect(normalized).toBeInstanceOf(FakeCommandError);
expect((normalized as FakeCommandError).stderr).toMatch(/timed out after 100ms/);
});
});
describe("createCachedCliPathResolver", () => {
it("resolves once and reuses the cached path on later calls", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return "/usr/bin/forge-cli";
});
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
expect(calls).toBe(1);
});
it("retries on the next call after a miss instead of caching null forever", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return calls === 1 ? null : "/usr/bin/forge-cli";
});
await expect(resolveCliPath()).resolves.toBeNull();
await expect(resolveCliPath()).resolves.toBe("/usr/bin/forge-cli");
expect(calls).toBe(2);
});
it("coalesces concurrent callers onto a single in-flight resolution", async () => {
let calls = 0;
const resolveCliPath = createCachedCliPathResolver(async () => {
calls += 1;
return "/usr/bin/forge-cli";
});
await Promise.all([resolveCliPath(), resolveCliPath(), resolveCliPath()]);
expect(calls).toBe(1);
});
});