104 lines
6.4 KiB
TypeScript
104 lines
6.4 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { githubBrokerEnvironment, githubLauncherSource } from "./github-launcher.js";
|
|
import { prepareGitHubOperationLaunchers } from "./execution-target.js";
|
|
import { runChildProcess } from "./server-utils.js";
|
|
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
|
const exec = promisify(execFile);
|
|
const cleanups: Array<() => Promise<unknown>> = [];
|
|
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
|
|
|
|
describe("managed GitHub launchers", () => {
|
|
it("runs staged git and gh launchers inside an ES-module repository", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-esm-"));
|
|
cleanups.push(() => rm(root, { recursive: true, force: true }));
|
|
const realBin = path.join(root, "real-bin");
|
|
await mkdir(realBin);
|
|
await writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" }));
|
|
await writeFile(path.join(realBin, "gh"), "#!/bin/sh\nprintf gh-fixture", { mode: 0o700 });
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const,
|
|
providerKey: "fixture", remoteCwd: root,
|
|
runner: { execute: async (input: Parameters<CommandManagedRuntimeRunner["execute"]>[0]) => runChildProcess("github-esm-fixture", input.command, input.args ?? [], {
|
|
cwd: input.cwd ?? root, env: input.env ?? {}, stdin: input.stdin,
|
|
timeoutSec: 15, graceSec: 1, onLog: async () => {},
|
|
}) },
|
|
};
|
|
// The second run models the fresh per-run wrapper staged on warm startup.
|
|
for (const runId of ["cold", "warm"]) {
|
|
const env = await prepareGitHubOperationLaunchers({ runId, target, cwd: root,
|
|
env: { PATH: `${realBin}:${process.env.PATH}`, PAPERCLIP_GITHUB_BROKER_TOKEN: "" } });
|
|
const launcher = env.PAPERCLIP_GITHUB_LAUNCHER_DIR;
|
|
expect((await exec(path.join(launcher, "git"), ["--version"], { cwd: root, env: { ...process.env, ...env } })).stdout).toMatch(/^git version /);
|
|
expect((await exec(path.join(launcher, "gh"), [], { cwd: root, env: { ...process.env, ...env } })).stdout).toBe("gh-fixture");
|
|
}
|
|
});
|
|
|
|
it("captures each command's identity and clears host credentials when the next person has none", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-launcher-test-"));
|
|
cleanups.push(() => rm(root, { recursive: true, force: true }));
|
|
const bin = path.join(root, "managed"), realBin = path.join(root, "real"), repo = path.join(root, "repo");
|
|
for (const dir of [bin, realBin, repo, path.join(bin, "gh-config")]) await mkdir(dir, { recursive: true });
|
|
for (const name of ["git", "gh"]) await writeFile(path.join(bin, name), githubLauncherSource(), { mode: 0o700 });
|
|
await writeFile(path.join(realBin, "gh"), `#!/usr/bin/env node
|
|
const {execFileSync}=require('node:child_process');
|
|
const identity=execFileSync('git',['var','GIT_AUTHOR_IDENT'],{encoding:'utf8'}).trim();
|
|
process.stdout.write(JSON.stringify({identity, token:process.env.GH_TOKEN ?? null, global:process.env.GIT_CONFIG_GLOBAL, config:process.env.GH_CONFIG_DIR}));
|
|
`, { mode: 0o700 });
|
|
let user: string | null = "A", captures = 0;
|
|
let heldCapture: (() => void) | null = null;
|
|
let releaseCapture: (() => void) | null = null;
|
|
const server = createServer((req, res) => {
|
|
captures++;
|
|
expect(req.headers.authorization).toBe("Bearer run-capability");
|
|
const selected = user;
|
|
res.setHeader("content-type", "application/json");
|
|
const finish = () => res.end(JSON.stringify(selected ? { status: "available", env: {
|
|
GH_TOKEN: `credential-${selected}`, GITHUB_TOKEN: `credential-${selected}`,
|
|
GIT_AUTHOR_NAME: selected, GIT_AUTHOR_EMAIL: `${selected}@example.test`,
|
|
GIT_COMMITTER_NAME: selected, GIT_COMMITTER_EMAIL: `${selected}@example.test`,
|
|
} } : { status: "absent", env: {} }));
|
|
if (heldCapture) { const captured = heldCapture; heldCapture = null; releaseCapture = finish; captured(); }
|
|
else finish();
|
|
});
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
cleanups.push(() => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())));
|
|
const address = server.address() as { port: number };
|
|
const env: NodeJS.ProcessEnv = { ...process.env, ...githubBrokerEnvironment({
|
|
GH_TOKEN: "ambient-host-token", GIT_AUTHOR_NAME: "Host", GIT_AUTHOR_EMAIL: "host@example.test",
|
|
}, { url: `http://127.0.0.1:${address.port}`, token: "run-capability" }), PATH: `${bin}:${realBin}:${process.env.PATH}` };
|
|
const git = async (...args: string[]) => (await exec(path.join(bin, "git"), args, { cwd: repo, env })).stdout.trim();
|
|
await git("init");
|
|
await git("commit", "--allow-empty", "-m", "A");
|
|
user = "B";
|
|
await git("commit", "--allow-empty", "-m", "B");
|
|
user = "A";
|
|
await git("commit", "--allow-empty", "-m", "A again");
|
|
expect(await git("log", "--format=%an <%ae>|%cn <%ce>" )).toBe("A <A@example.test>|A <A@example.test>\nB <B@example.test>|B <B@example.test>\nA <A@example.test>|A <A@example.test>");
|
|
const before = captures;
|
|
const gh = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
|
|
expect(gh.identity).toContain("A <A@example.test>");
|
|
expect(gh.token).toBe("credential-A");
|
|
expect(captures - before).toBe(1); // gh's child Git retains the same capture.
|
|
const captured = new Promise<void>(resolve => { heldCapture = resolve; });
|
|
const operationA = exec(path.join(bin, "gh"), [], { cwd: repo, env });
|
|
await captured;
|
|
user = "B";
|
|
const operationB = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
|
|
releaseCapture!();
|
|
const completedA = JSON.parse((await operationA).stdout);
|
|
expect(completedA.token).toBe("credential-A");
|
|
expect(operationB.token).toBe("credential-B");
|
|
expect(completedA.config).not.toBe(operationB.config);
|
|
user = null;
|
|
await expect(git("var", "GIT_AUTHOR_IDENT")).rejects.toThrow();
|
|
expect(await git("status", "--porcelain")).toBe(""); // unrelated public/local Git still works
|
|
expect(env.GH_TOKEN).toBe("");
|
|
expect(env.GIT_AUTHOR_NAME).toBe("");
|
|
});
|
|
});
|