mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
170 lines
9.5 KiB
TypeScript
170 lines
9.5 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
const ROOT = path.resolve(import.meta.dir, "..");
|
|
const BIN = path.join(ROOT, "bin");
|
|
const temporaryRoots: string[] = [];
|
|
|
|
function run(command: string, args: string[], cwd: string, home?: string) {
|
|
const env = {
|
|
...process.env,
|
|
...(home ? { GSTACK_HOME: home, GSTACK_STATE_ROOT: home } : {}),
|
|
GSTACK_QUESTION_LOG_NO_DERIVE: "1",
|
|
};
|
|
const result = spawnSync(command, args, { cwd, env, encoding: "utf8", timeout: 15_000 });
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${result.stderr || result.stdout}`);
|
|
}
|
|
return result.stdout || "";
|
|
}
|
|
|
|
function helper(name: string, args: string[], cwd: string, home: string) {
|
|
return run(path.join(BIN, name), args, cwd, home);
|
|
}
|
|
|
|
function parseIdentity(cwd: string, home: string) {
|
|
const output = helper("gstack-slug", [], cwd, home);
|
|
const value = (key: string) => output.match(new RegExp(`^${key}=([a-zA-Z0-9._-]+)$`, "m"))?.[1] ?? "unknown";
|
|
return {
|
|
slug: value("SLUG"),
|
|
projectId: value("PROJECT_ID"),
|
|
repoId: value("REPO_ID"),
|
|
worktreeId: value("WORKTREE_ID"),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of temporaryRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("preserved helper project-state identity", () => {
|
|
test("all paired local project readers and writers use PROJECT_ID, not SLUG", () => {
|
|
const helpers = [
|
|
"gstack-timeline-log", "gstack-timeline-read",
|
|
"gstack-learnings-log", "gstack-learnings-search",
|
|
"gstack-question-log", "gstack-question-preference",
|
|
"gstack-review-log", "gstack-review-read", "gstack-specialist-stats",
|
|
"gstack-distill-free-text", "gstack-distill-apply", "gstack-developer-profile",
|
|
];
|
|
for (const name of helpers) {
|
|
const source = fs.readFileSync(path.join(BIN, name), "utf8");
|
|
expect(source).not.toMatch(/projects\/\$\{?SLUG\}?/);
|
|
}
|
|
const hook = fs.readFileSync(path.join(ROOT, "hosts", "claude", "hooks", "question-preference-hook.ts"), "utf8");
|
|
expect(hook).not.toContain("slugFromCwd");
|
|
expect(hook).toContain("discoverProjectIdentity");
|
|
});
|
|
|
|
test("identity infrastructure failures never collapse writes into projects/unknown", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-helper-identity-failure-"));
|
|
temporaryRoots.push(root);
|
|
const cwd = path.join(root, "checkout");
|
|
const home = path.join(root, "state");
|
|
const fakeBin = path.join(root, "fake-bin");
|
|
fs.mkdirSync(cwd);
|
|
fs.mkdirSync(fakeBin);
|
|
const fakeGit = path.join(fakeBin, "git");
|
|
fs.writeFileSync(fakeGit, "#!/bin/sh\nexit 2\n", { mode: 0o755 });
|
|
const result = spawnSync(path.join(BIN, "gstack-timeline-log"), [JSON.stringify({ skill: "qa", event: "completed" })], {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
GSTACK_HOME: home,
|
|
PATH: [fakeBin, process.env.PATH || ""].join(path.delimiter),
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(fs.existsSync(path.join(home, "projects", "unknown"))).toBe(false);
|
|
});
|
|
|
|
test("linked worktrees with the same remote never share local helper state", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-helper-worktrees-"));
|
|
temporaryRoots.push(root);
|
|
const main = path.join(root, "main checkout");
|
|
const linked = path.join(root, "linked checkout");
|
|
const home = path.join(root, "state home");
|
|
fs.mkdirSync(main);
|
|
|
|
run("git", ["init", "--quiet", "-b", "main"], main);
|
|
run("git", ["config", "user.name", "GStack Test"], main);
|
|
run("git", ["config", "user.email", "gstack@example.com"], main);
|
|
run("git", ["remote", "add", "origin", "https://example.com/acme/shared-project.git"], main);
|
|
for (let index = 1; index <= 5; index += 1) {
|
|
fs.writeFileSync(path.join(main, "counter.txt"), `${index}\n`);
|
|
run("git", ["add", "counter.txt"], main);
|
|
run("git", ["commit", "--quiet", "-m", `commit ${index}`], main);
|
|
}
|
|
run("git", ["worktree", "add", "--quiet", "-b", "feature", linked], main);
|
|
|
|
const mainIdentity = parseIdentity(main, home);
|
|
const linkedIdentity = parseIdentity(linked, home);
|
|
expect(mainIdentity.slug).toBe("acme-shared-project");
|
|
expect(linkedIdentity.slug).toBe(mainIdentity.slug);
|
|
expect(linkedIdentity.repoId).toBe(mainIdentity.repoId);
|
|
expect(linkedIdentity.worktreeId).not.toBe(mainIdentity.worktreeId);
|
|
expect(linkedIdentity.projectId).not.toBe(mainIdentity.projectId);
|
|
|
|
helper("gstack-timeline-log", [JSON.stringify({ skill: "qa", event: "completed", branch: "main" })], main, home);
|
|
helper("gstack-timeline-log", [JSON.stringify({ skill: "review", event: "completed", branch: "feature" })], linked, home);
|
|
helper("gstack-learnings-log", [JSON.stringify({ skill: "qa", type: "pattern", key: "main-only", insight: "main insight", confidence: 8, source: "observed" })], main, home);
|
|
helper("gstack-learnings-log", [JSON.stringify({ skill: "review", type: "pattern", key: "linked-only", insight: "linked insight", confidence: 8, source: "observed" })], linked, home);
|
|
helper("gstack-review-log", [JSON.stringify({ skill: "review", status: "main", specialists: { security: { dispatched: true, findings: 1 } } })], main, home);
|
|
helper("gstack-review-log", [JSON.stringify({ skill: "review", status: "linked", specialists: { performance: { dispatched: true, findings: 2 } } })], linked, home);
|
|
helper("gstack-decision-log", [JSON.stringify({ decision: "main decision", scope: "repo", source: "user" })], main, home);
|
|
helper("gstack-decision-log", [JSON.stringify({ decision: "linked decision", scope: "repo", source: "user" })], linked, home);
|
|
|
|
const question = (id: string) => JSON.stringify({
|
|
skill: "plan", question_id: id, question_summary: `${id} summary`, options_count: 2,
|
|
user_choice: "yes", recommended: "yes", session_id: id,
|
|
});
|
|
helper("gstack-question-log", [question("main-question")], main, home);
|
|
helper("gstack-question-log", [question("linked-question")], linked, home);
|
|
helper("gstack-question-preference", ["--write", JSON.stringify({ question_id: "main-pref", preference: "never-ask", source: "plan-tune" })], main, home);
|
|
helper("gstack-question-preference", ["--write", JSON.stringify({ question_id: "linked-pref", preference: "always-ask", source: "plan-tune" })], linked, home);
|
|
helper("gstack-taste-update", ["approved", "main-variant", "--reason", "fonts: Main Sans"], main, home);
|
|
helper("gstack-taste-update", ["approved", "linked-variant", "--reason", "fonts: Linked Sans"], linked, home);
|
|
helper("gstack-repo-mode", [], main, home);
|
|
helper("gstack-repo-mode", [], linked, home);
|
|
helper("gstack-brain-cache", ["invalidate", "product", "--project", mainIdentity.slug], main, home);
|
|
helper("gstack-brain-cache", ["invalidate", "product", "--project", linkedIdentity.slug], linked, home);
|
|
|
|
const mainDir = path.join(home, "projects", mainIdentity.projectId);
|
|
const linkedDir = path.join(home, "projects", linkedIdentity.projectId);
|
|
for (const dir of [mainDir, linkedDir]) expect(fs.statSync(dir).isDirectory()).toBe(true);
|
|
expect(fs.existsSync(path.join(home, "projects", mainIdentity.slug))).toBe(false);
|
|
|
|
expect(helper("gstack-timeline-read", [], main, home)).toContain("/qa completed");
|
|
expect(helper("gstack-timeline-read", [], main, home)).not.toContain("/review completed");
|
|
expect(helper("gstack-timeline-read", [], linked, home)).toContain("/review completed");
|
|
expect(helper("gstack-learnings-search", [], main, home)).toContain("main-only");
|
|
expect(helper("gstack-learnings-search", [], main, home)).not.toContain("linked-only");
|
|
expect(helper("gstack-learnings-search", [], linked, home)).toContain("linked-only");
|
|
|
|
expect(JSON.parse(helper("gstack-decision-search", ["--json"], main, home))).toEqual([
|
|
expect.objectContaining({ decision: "main decision" }),
|
|
]);
|
|
expect(JSON.parse(helper("gstack-decision-search", ["--json"], linked, home))).toEqual([
|
|
expect.objectContaining({ decision: "linked decision" }),
|
|
]);
|
|
expect(helper("gstack-review-read", [], main, home)).toContain('"status":"main"');
|
|
expect(helper("gstack-review-read", [], main, home)).not.toContain('"status":"linked"');
|
|
expect(helper("gstack-specialist-stats", [], main, home)).toContain("security: 1/1 dispatched");
|
|
expect(helper("gstack-specialist-stats", [], main, home)).not.toContain("performance:");
|
|
expect(JSON.parse(helper("gstack-question-preference", ["--read"], main, home))).toEqual({ "main-pref": "never-ask" });
|
|
expect(JSON.parse(helper("gstack-question-preference", ["--read"], linked, home))).toEqual({ "linked-pref": "always-ask" });
|
|
|
|
expect(fs.readFileSync(path.join(mainDir, "question-log.jsonl"), "utf8")).toContain("main-question");
|
|
expect(fs.readFileSync(path.join(mainDir, "question-log.jsonl"), "utf8")).not.toContain("linked-question");
|
|
expect(fs.readFileSync(path.join(mainDir, "taste-profile.json"), "utf8")).toContain("Main Sans");
|
|
expect(fs.readFileSync(path.join(mainDir, "taste-profile.json"), "utf8")).not.toContain("Linked Sans");
|
|
expect(fs.existsSync(path.join(mainDir, "repo-mode.json"))).toBe(true);
|
|
expect(fs.existsSync(path.join(linkedDir, "repo-mode.json"))).toBe(true);
|
|
expect(fs.existsSync(path.join(mainDir, "brain-cache", "_meta.json"))).toBe(true);
|
|
expect(fs.existsSync(path.join(linkedDir, "brain-cache", "_meta.json"))).toBe(true);
|
|
}, 30_000);
|
|
});
|