mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
beginRun,
|
|
appendDecision,
|
|
completeRun,
|
|
configSet,
|
|
discoverProjectIdentity,
|
|
identityFromPaths,
|
|
initializeProject,
|
|
inspectProject,
|
|
markEffectApplied,
|
|
markEffectNotApplied,
|
|
resumeRun,
|
|
resolveGstackHome,
|
|
runExternalEffect,
|
|
setupRuntime,
|
|
updateProjectState,
|
|
withLock,
|
|
} from "../runtime/index.js";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
async function temporaryRoot(label = "gstack2 runtime ") {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), label));
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const root of temporaryRoots.splice(0)) {
|
|
await fs.chmod(root, 0o700).catch(() => {});
|
|
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
}
|
|
});
|
|
|
|
describe("gstack 2 host-neutral paths and state", () => {
|
|
test("GSTACK_HOME is the only override and shell-looking paths stay literal", async () => {
|
|
const root = await temporaryRoot();
|
|
const configured = "state with spaces $(touch should-not-run);$HOME";
|
|
const resolved = resolveGstackHome({
|
|
env: { GSTACK_HOME: configured },
|
|
cwd: root,
|
|
homeDir: path.join(root, "fake-home"),
|
|
});
|
|
expect(resolved).toBe(path.join(root, configured));
|
|
expect(resolveGstackHome({
|
|
env: { CLAUDE_PLUGIN_DATA: "/host-specific/path" },
|
|
cwd: root,
|
|
homeDir: path.join(root, "person"),
|
|
})).toBe(path.join(root, "person", ".gstack"));
|
|
expect(await fs.readdir(root)).toEqual([]);
|
|
});
|
|
|
|
test("setup creates the canonical project shape and private secrets", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "home with spaces");
|
|
const result = await setupRuntime({ home, cwd: root });
|
|
const project = path.join(home, "projects", result.identity.projectId);
|
|
for (const entry of [
|
|
"state.json", "timeline.jsonl", "decisions.jsonl", "evidence", "artifacts", "reviews", "checkpoints",
|
|
]) {
|
|
expect(await fs.stat(path.join(project, entry))).toBeTruthy();
|
|
}
|
|
if (process.platform !== "win32") {
|
|
expect((await fs.stat(path.join(home, "secrets.json"))).mode & 0o777).toBe(0o600);
|
|
}
|
|
});
|
|
|
|
test("public config rejects key, token, and credential-looking fields", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
await setupRuntime({ home, cwd: root });
|
|
for (const key of ["context.apiKey", "context.api_key", "context.token", "service.accessToken", "service.credentials"]) {
|
|
let error: any;
|
|
try {
|
|
await configSet(home, key, "must-not-be-public");
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
expect(error?.code).toBe("SECRET_IN_CONFIG");
|
|
}
|
|
});
|
|
|
|
test("locked concurrent updates do not lose writes", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: path.join(root, "repo"),
|
|
commonDir: path.join(root, "repo", ".git"),
|
|
gitDir: path.join(root, "repo", ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
const updates = await Promise.allSettled(Array.from({ length: 60 }, () =>
|
|
updateProjectState(home, identity.projectId, (state) => {
|
|
state.concurrentCounter = Number(state.concurrentCounter ?? 0) + 1;
|
|
})));
|
|
expect(updates.filter((result) => result.status === "rejected")).toEqual([]);
|
|
const { state } = await inspectProject(home, identity);
|
|
expect(state.concurrentCounter).toBe(60);
|
|
expect(state.revision).toBe(60);
|
|
}, 15_000);
|
|
|
|
test("crash/resume never automatically repeats an uncertain external effect", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: path.join(root, "repo"),
|
|
commonDir: path.join(root, "repo", ".git"),
|
|
gitDir: path.join(root, "repo", ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
const { run } = await beginRun(home, identity.projectId, "ship", { runId: "run_crash_test" });
|
|
let externalCalls = 0;
|
|
let firstError: any;
|
|
try {
|
|
await runExternalEffect(home, identity.projectId, run.id, "publish.release", async () => {
|
|
externalCalls += 1;
|
|
// This models a connection drop after the remote service accepted the action.
|
|
throw new Error("connection dropped after accept");
|
|
});
|
|
} catch (error) {
|
|
firstError = error;
|
|
}
|
|
expect(firstError?.code).toBe("EXTERNAL_EFFECT_UNCERTAIN");
|
|
await resumeRun(home, identity.projectId, run.id);
|
|
const retried = await runExternalEffect(home, identity.projectId, run.id, "publish.release", async () => {
|
|
externalCalls += 1;
|
|
return "duplicated";
|
|
});
|
|
expect(retried.status).toBe("uncertain");
|
|
expect(externalCalls).toBe(1);
|
|
});
|
|
|
|
test("completed external effects are idempotent", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: root,
|
|
commonDir: path.join(root, ".git"),
|
|
gitDir: path.join(root, ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
const { run } = await beginRun(home, identity.projectId, "notify", { runId: "run_once" });
|
|
let calls = 0;
|
|
const execute = async () => ({ sequence: ++calls });
|
|
expect((await runExternalEffect(home, identity.projectId, run.id, "notify.once", execute)).result).toEqual({ sequence: 1 });
|
|
expect((await runExternalEffect(home, identity.projectId, run.id, "notify.once", execute)).result).toEqual({ sequence: 1 });
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
test("ambiguous effects reconcile both outcomes without permitting incomplete runs", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: root,
|
|
commonDir: path.join(root, ".git"),
|
|
gitDir: path.join(root, ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
|
|
const appliedRun = (await beginRun(home, identity.projectId, "ship", { runId: "run_applied" })).run;
|
|
await expect(runExternalEffect(home, identity.projectId, appliedRun.id, "git.push", async () => {
|
|
throw new Error("connection dropped after remote accepted push");
|
|
})).rejects.toMatchObject({ code: "EXTERNAL_EFFECT_UNCERTAIN" });
|
|
await markEffectApplied(home, identity.projectId, appliedRun.id, "git.push", "origin/main contains commit abc123");
|
|
await expect(completeRun(home, identity.projectId, appliedRun.id)).resolves.toBeTruthy();
|
|
|
|
const retryRun = (await beginRun(home, identity.projectId, "ship", { runId: "run_not_applied" })).run;
|
|
await expect(runExternalEffect(home, identity.projectId, retryRun.id, "deploy.production", async () => {
|
|
throw new Error("preflight failed before request");
|
|
})).rejects.toMatchObject({ code: "EXTERNAL_EFFECT_UNCERTAIN" });
|
|
await markEffectNotApplied(home, identity.projectId, retryRun.id, "deploy.production");
|
|
await expect(completeRun(home, identity.projectId, retryRun.id))
|
|
.rejects.toMatchObject({ code: "EFFECTS_UNCERTAIN" });
|
|
await runExternalEffect(home, identity.projectId, retryRun.id, "deploy.production", async () => "deployed");
|
|
await expect(completeRun(home, identity.projectId, retryRun.id)).resolves.toBeTruthy();
|
|
});
|
|
|
|
test("state keys cannot collide with object prototypes and effect idempotency keys do not truncate", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: root,
|
|
commonDir: path.join(root, ".git"),
|
|
gitDir: path.join(root, ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
await expect(beginRun(home, identity.projectId, "ship", { runId: "constructor" })).rejects.toThrow("Invalid run id");
|
|
const { run } = await beginRun(home, identity.projectId, "ship", { runId: "run_keys" });
|
|
await expect(runExternalEffect(home, identity.projectId, run.id, "constructor", async () => null))
|
|
.rejects.toThrow("Invalid external effect key");
|
|
|
|
const commonPrefix = `publish.${"a".repeat(110)}`;
|
|
const first = await runExternalEffect(home, identity.projectId, run.id, `${commonPrefix}x`, async () => "first");
|
|
const second = await runExternalEffect(home, identity.projectId, run.id, `${commonPrefix}y`, async () => "second");
|
|
expect(first.idempotencyKey).toMatch(/^gstack_[0-9a-f]{64}$/);
|
|
expect(second.idempotencyKey).toMatch(/^gstack_[0-9a-f]{64}$/);
|
|
expect(first.idempotencyKey).not.toBe(second.idempotencyKey);
|
|
await expect(markEffectNotApplied(home, identity.projectId, run.id, `${commonPrefix}x`))
|
|
.rejects.toMatchObject({ code: "EFFECT_NOT_UNCERTAIN" });
|
|
});
|
|
|
|
test("decision provenance fields cannot be overwritten by caller input", async () => {
|
|
const root = await temporaryRoot();
|
|
const home = path.join(root, "state");
|
|
const identity = identityFromPaths({
|
|
worktreeRoot: root,
|
|
commonDir: path.join(root, ".git"),
|
|
gitDir: path.join(root, ".git"),
|
|
});
|
|
await initializeProject(home, identity);
|
|
const record = await appendDecision(home, identity.projectId, {
|
|
id: "forged",
|
|
at: "1900-01-01T00:00:00.000Z",
|
|
decision: "keep scope",
|
|
}, { now: () => new Date("2026-07-16T12:00:00.000Z") });
|
|
expect(record.id).not.toBe("forged");
|
|
expect(record.at).toBe("2026-07-16T12:00:00.000Z");
|
|
});
|
|
|
|
test("repo identity is shared while linked worktree identity is stable and distinct", async () => {
|
|
const root = await temporaryRoot("gstack2 worktree identity ");
|
|
const repo = path.join(root, "main repo");
|
|
const linked = path.join(root, "linked worktree");
|
|
await fs.mkdir(repo);
|
|
const git = (args: string[], cwd = repo) => spawnSync("git", args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
timeout: 15_000,
|
|
});
|
|
expect(git(["init", "--quiet", "-b", "main"]).status).toBe(0);
|
|
git(["config", "user.email", "runtime@example.com"]);
|
|
git(["config", "user.name", "Runtime Test"]);
|
|
await fs.writeFile(path.join(repo, "README.md"), "runtime\n");
|
|
git(["add", "README.md"]);
|
|
expect(git(["commit", "--quiet", "-m", "initial"]).status).toBe(0);
|
|
expect(git(["worktree", "add", "--quiet", "-b", "linked", linked]).status).toBe(0);
|
|
|
|
const mainIdentity = await discoverProjectIdentity(repo);
|
|
const linkedIdentity = await discoverProjectIdentity(linked);
|
|
expect(linkedIdentity.repoId).toBe(mainIdentity.repoId);
|
|
expect(linkedIdentity.worktreeId).not.toBe(mainIdentity.worktreeId);
|
|
expect((await discoverProjectIdentity(linked)).worktreeId).toBe(linkedIdentity.worktreeId);
|
|
expect(linkedIdentity.projectId).not.toBe(mainIdentity.projectId);
|
|
});
|
|
|
|
test("linked-worktree identity survives a checkout move and git infrastructure failures do not become non-git", async () => {
|
|
const root = await temporaryRoot();
|
|
const common = path.join(root, "repo", ".git");
|
|
const gitDir = path.join(common, "worktrees", "feature");
|
|
const before = identityFromPaths({ worktreeRoot: path.join(root, "old checkout"), commonDir: common, gitDir });
|
|
const after = identityFromPaths({ worktreeRoot: path.join(root, "new checkout"), commonDir: common, gitDir });
|
|
expect(after.repoId).toBe(before.repoId);
|
|
expect(after.worktreeId).toBe(before.worktreeId);
|
|
expect(after.projectId).toBe(before.projectId);
|
|
|
|
const notGit = Object.assign(new Error("fatal: not a git repository"), {
|
|
code: 128,
|
|
stderr: "fatal: not a git repository",
|
|
});
|
|
const folder = await fs.mkdtemp(path.join(root, "plain "));
|
|
expect((await discoverProjectIdentity(folder, { git: async () => { throw notGit; } })).isGit).toBe(false);
|
|
|
|
const timeout = Object.assign(new Error("git timed out"), { code: "ETIMEDOUT" });
|
|
await expect(discoverProjectIdentity(folder, { git: async () => { throw timeout; } }))
|
|
.rejects.toMatchObject({ code: "ETIMEDOUT" });
|
|
});
|
|
|
|
test("setup reports a read-only destination where the platform enforces modes", async () => {
|
|
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
const root = await temporaryRoot();
|
|
const readOnly = path.join(root, "read-only");
|
|
await fs.mkdir(readOnly, { mode: 0o500 });
|
|
await fs.chmod(readOnly, 0o500);
|
|
try {
|
|
let failed = false;
|
|
try {
|
|
await setupRuntime({ home: path.join(readOnly, "state"), cwd: root });
|
|
} catch (error: any) {
|
|
failed = ["EACCES", "EPERM", "EROFS"].includes(error?.code);
|
|
}
|
|
expect(failed).toBe(true);
|
|
} finally {
|
|
await fs.chmod(readOnly, 0o700);
|
|
}
|
|
});
|
|
|
|
test("lock cleanup preserves both the operation and release failures", async () => {
|
|
const root = await temporaryRoot("gstack2 lock aggregate ");
|
|
const lock = path.join(root, "state.lock");
|
|
const operationError = null;
|
|
let caught: unknown;
|
|
try {
|
|
await withLock(lock, async () => {
|
|
await fs.writeFile(path.join(lock, "owner.json"), "{malformed\n");
|
|
throw operationError;
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(AggregateError);
|
|
const failures = [...(caught as AggregateError).errors];
|
|
expect(failures[0]).toBe(operationError);
|
|
expect(failures[1]).toBeInstanceOf(SyntaxError);
|
|
expect((caught as Error & { cause?: unknown }).cause).toBe(operationError);
|
|
});
|
|
});
|