mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
579 lines
23 KiB
TypeScript
579 lines
23 KiB
TypeScript
/**
|
|
* In-process tests for design daemon endpoints + lifecycle helpers.
|
|
*
|
|
* Uses the exported fetchHandler directly (no Bun.serve spawn) so the suite
|
|
* is fast and deterministic. Spawn-based tests live in
|
|
* daemon-discovery.test.ts.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
import { __testInternals__, fetchHandler, idleCheckTick } from "../src/daemon";
|
|
|
|
const { markMeaningfulActivity } = __testInternals__;
|
|
import { makeBoardHtml, makeTmpDir, req, resetDaemon } from "./daemon-tests-fixtures";
|
|
import { usePrivateStateRoot } from "../../test/helpers/private-state-root";
|
|
|
|
const stateRoot = usePrivateStateRoot();
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
resetDaemon();
|
|
tmpDir = makeTmpDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
});
|
|
|
|
async function publishTestBoard(opts: { dir?: string; body?: string; title?: string } = {}) {
|
|
const dir = opts.dir ?? tmpDir;
|
|
const htmlPath = makeBoardHtml(dir, opts.body ?? "<p>Test</p>");
|
|
const r = await fetchHandler(
|
|
req("POST", "/api/boards", { html: htmlPath, title: opts.title }),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
const body = (await r.json()) as { id: string; url: string; sourceDir: string };
|
|
return { ...body, htmlPath, dir };
|
|
}
|
|
|
|
// ─── /health ─────────────────────────────────────────────────────
|
|
|
|
describe("daemon /health", () => {
|
|
test("returns ok=true with version + boards counts", async () => {
|
|
const r = await fetchHandler(req("GET", "/health"));
|
|
expect(r.status).toBe(200);
|
|
const body = (await r.json()) as any;
|
|
expect(body.ok).toBe(true);
|
|
expect(typeof body.version).toBe("string");
|
|
expect(body.boards).toBe(0);
|
|
expect(body.activeBoards).toBe(0);
|
|
expect(typeof body.uptime).toBe("number");
|
|
});
|
|
|
|
test("activeBoards counts non-done after publish", async () => {
|
|
await publishTestBoard();
|
|
const r = await fetchHandler(req("GET", "/health"));
|
|
const body = (await r.json()) as any;
|
|
expect(body.boards).toBe(1);
|
|
expect(body.activeBoards).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── POST /api/boards (publish) ─────────────────────────────────
|
|
|
|
describe("daemon /api/boards (publish)", () => {
|
|
test("publishes a board and returns id + url + derived sourceDir", async () => {
|
|
const htmlPath = makeBoardHtml(tmpDir);
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
|
|
expect(r.status).toBe(200);
|
|
const body = (await r.json()) as any;
|
|
expect(body.id).toMatch(/^b-\d{8}-\d{6}-[a-z0-9]{6}$/);
|
|
expect(body.url).toMatch(/\/boards\/b-\d{8}-\d{6}-[a-z0-9]{6}\/$/); // trailing slash
|
|
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
|
|
});
|
|
|
|
test("rejects when html field missing", async () => {
|
|
const r = await fetchHandler(req("POST", "/api/boards", { title: "noop" }));
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("Missing 'html'");
|
|
});
|
|
|
|
test("rejects when html file does not exist", async () => {
|
|
const r = await fetchHandler(
|
|
req("POST", "/api/boards", { html: "/tmp/does-not-exist.html" }),
|
|
);
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("not found");
|
|
});
|
|
|
|
test("rejects when html points at a directory", async () => {
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: tmpDir }));
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("must be a file");
|
|
});
|
|
|
|
test("ignores body-supplied sourceDir; derives from realpath(html) instead", async () => {
|
|
const htmlPath = makeBoardHtml(tmpDir);
|
|
const otherDir = makeTmpDir("sneaky");
|
|
try {
|
|
const r = await fetchHandler(
|
|
req("POST", "/api/boards", { html: htmlPath, sourceDir: otherDir }),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
const body = (await r.json()) as any;
|
|
// The daemon used the realpath of the HTML's dir, NOT the body field.
|
|
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
|
|
expect(body.sourceDir).not.toBe(fs.realpathSync(otherDir));
|
|
} finally {
|
|
try {
|
|
fs.rmSync(otherDir, { recursive: true, force: true });
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}
|
|
});
|
|
|
|
test("409 when a non-done board already claims the same sourceDir", async () => {
|
|
const first = await publishTestBoard();
|
|
const htmlPath = makeBoardHtml(tmpDir, "<p>Second attempt</p>");
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
|
|
expect(r.status).toBe(409);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("already in use");
|
|
expect(body.existing.id).toBe(first.id);
|
|
expect(body.existing.url).toContain(`/boards/${first.id}/`);
|
|
});
|
|
|
|
test("allows publish to same sourceDir after the prior board is done", async () => {
|
|
const first = await publishTestBoard();
|
|
// Submit the first board so it becomes done
|
|
await fetchHandler(
|
|
req("POST", `/boards/${first.id}/api/feedback`, { regenerated: false }),
|
|
);
|
|
const htmlPath = makeBoardHtml(tmpDir, "<p>Round two</p>");
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
|
|
expect(r.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─── GET /boards/<id> trailing-slash redirect ────────────────────
|
|
|
|
describe("daemon /boards/<id> trailing-slash redirect", () => {
|
|
test("GET /boards/<id> returns 301 with Location /boards/<id>/", async () => {
|
|
const board = await publishTestBoard();
|
|
const r = await fetchHandler(req("GET", `/boards/${board.id}`));
|
|
expect(r.status).toBe(301);
|
|
expect(r.headers.get("Location")).toBe(`/boards/${board.id}/`);
|
|
});
|
|
|
|
test("GET /boards/<id>/ renders the board's HTML", async () => {
|
|
const board = await publishTestBoard({ body: "<p>Hello from board</p>" });
|
|
const r = await fetchHandler(req("GET", `/boards/${board.id}/`));
|
|
expect(r.status).toBe(200);
|
|
expect(r.headers.get("Content-Type") || "").toContain("text/html");
|
|
const html = await r.text();
|
|
expect(html).toContain("Hello from board");
|
|
// No __GSTACK_SERVER_URL injection (board JS uses relative paths)
|
|
expect(html).not.toContain("__GSTACK_SERVER_URL");
|
|
});
|
|
|
|
test("404 on unknown board id (shows expired page)", async () => {
|
|
const r = await fetchHandler(req("GET", "/boards/b-nonexistent/"));
|
|
expect(r.status).toBe(404);
|
|
const html = await r.text();
|
|
expect(html).toContain("Board expired");
|
|
});
|
|
});
|
|
|
|
// ─── POST /boards/<id>/api/feedback ──────────────────────────────
|
|
|
|
describe("daemon /boards/<id>/api/feedback", () => {
|
|
test("submit writes feedback.json to derived sourceDir with boardId + publishedAt", async () => {
|
|
const board = await publishTestBoard();
|
|
const feedback = { preferred: "A", ratings: { A: 5 }, regenerated: false };
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/feedback`, feedback),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
expect(((await r.json()) as any).action).toBe("submitted");
|
|
|
|
const written = JSON.parse(
|
|
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
|
|
);
|
|
expect(written.preferred).toBe("A");
|
|
expect(written.regenerated).toBe(false);
|
|
expect(written.boardId).toBe(board.id);
|
|
expect(typeof written.publishedAt).toBe("string");
|
|
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
test("regenerate writes feedback-pending.json and flips state to regenerating", async () => {
|
|
const board = await publishTestBoard();
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/feedback`, {
|
|
regenerated: true,
|
|
regenerateAction: "more_like_A",
|
|
}),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
expect(((await r.json()) as any).action).toBe("regenerate");
|
|
|
|
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
|
|
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
|
|
|
|
const progress = await fetchHandler(
|
|
req("GET", `/boards/${board.id}/api/progress`),
|
|
);
|
|
expect(((await progress.json()) as any).status).toBe("regenerating");
|
|
});
|
|
|
|
test("cross-board isolation: feedback writes only into that board's sourceDir", async () => {
|
|
const dirA = makeTmpDir("board-a");
|
|
const dirB = makeTmpDir("board-b");
|
|
try {
|
|
const htmlA = makeBoardHtml(dirA);
|
|
const htmlB = makeBoardHtml(dirB);
|
|
const a = (await (await fetchHandler(
|
|
req("POST", "/api/boards", { html: htmlA }),
|
|
)).json()) as any;
|
|
const b = (await (await fetchHandler(
|
|
req("POST", "/api/boards", { html: htmlB }),
|
|
)).json()) as any;
|
|
expect(a.id).not.toBe(b.id);
|
|
|
|
await fetchHandler(
|
|
req("POST", `/boards/${a.id}/api/feedback`, { preferred: "A", regenerated: false }),
|
|
);
|
|
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
|
|
// Board B's directory must not have been touched
|
|
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
|
|
expect(fs.existsSync(path.join(b.sourceDir, "feedback-pending.json"))).toBe(false);
|
|
} finally {
|
|
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
|
|
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
|
|
}
|
|
});
|
|
|
|
test("rejects malformed JSON body", async () => {
|
|
const board = await publishTestBoard();
|
|
const bad = new Request(`http://127.0.0.1/boards/${board.id}/api/feedback`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{not json",
|
|
});
|
|
const r = await fetchHandler(bad);
|
|
expect(r.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ─── POST /boards/<id>/api/reload ────────────────────────────────
|
|
|
|
describe("daemon /boards/<id>/api/reload", () => {
|
|
test("swaps HTML in place; subsequent GET returns new content", async () => {
|
|
const board = await publishTestBoard({ body: "<p>round 1</p>" });
|
|
const newHtml = makeBoardHtml(tmpDir, "<p>round 2</p>");
|
|
// The reload helper writes to design-board.html; make a distinct path
|
|
fs.writeFileSync(path.join(tmpDir, "round2.html"), "<html><body><p>round 2</p></body></html>");
|
|
const reloadPath = path.join(tmpDir, "round2.html");
|
|
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/reload`, { html: reloadPath }),
|
|
);
|
|
expect(r.status).toBe(200);
|
|
|
|
const page = await fetchHandler(req("GET", `/boards/${board.id}/`));
|
|
expect(await page.text()).toContain("round 2");
|
|
});
|
|
|
|
test("rejects path traversal outside allowedDir", async () => {
|
|
const board = await publishTestBoard();
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/reload`, { html: "/etc/passwd" }),
|
|
);
|
|
expect(r.status).toBe(403);
|
|
});
|
|
|
|
test("rejects directory path (Codex finding regression guard)", async () => {
|
|
const board = await publishTestBoard();
|
|
const sub = path.join(tmpDir, "subdir");
|
|
fs.mkdirSync(sub, { recursive: true });
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/reload`, { html: sub }),
|
|
);
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("must be a file");
|
|
});
|
|
|
|
test("rejects symlink pointing out of allowedDir", async () => {
|
|
const board = await publishTestBoard();
|
|
const linkPath = path.join(tmpDir, "evil.html");
|
|
try {
|
|
fs.symlinkSync("/etc/passwd", linkPath);
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/reload`, { html: linkPath }),
|
|
);
|
|
expect(r.status).toBe(403);
|
|
} finally {
|
|
try { fs.unlinkSync(linkPath); } catch {}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── GET / (index) ───────────────────────────────────────────────
|
|
|
|
describe("daemon / (index)", () => {
|
|
test("empty state shows the no-boards message", async () => {
|
|
const r = await fetchHandler(req("GET", "/"));
|
|
expect(r.status).toBe(200);
|
|
const html = await r.text();
|
|
expect(html).toContain("No boards yet");
|
|
});
|
|
|
|
test("lists boards newest first with state badges", async () => {
|
|
const a = await publishTestBoard({ title: "first" });
|
|
// Small wait so publishedAt differs
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
const dirB = makeTmpDir("index-b");
|
|
try {
|
|
const htmlB = makeBoardHtml(dirB);
|
|
const b = (await (await fetchHandler(
|
|
req("POST", "/api/boards", { html: htmlB, title: "second" }),
|
|
)).json()) as any;
|
|
|
|
const html = await (await fetchHandler(req("GET", "/"))).text();
|
|
const idxA = html.indexOf(a.id);
|
|
const idxB = html.indexOf(b.id);
|
|
// Newest first: b appears before a
|
|
expect(idxB).toBeGreaterThanOrEqual(0);
|
|
expect(idxA).toBeGreaterThan(idxB);
|
|
// State badge present
|
|
expect(html).toMatch(/state-serving/);
|
|
} finally {
|
|
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── /shutdown ───────────────────────────────────────────────────
|
|
|
|
describe("daemon /shutdown", () => {
|
|
test("refuses /shutdown when boards are non-done", async () => {
|
|
await publishTestBoard();
|
|
const r = await fetchHandler(req("POST", "/shutdown"));
|
|
expect(r.status).toBe(409);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("active boards");
|
|
expect(body.activeBoards).toBe(1);
|
|
});
|
|
|
|
test("accepts /shutdown when no active boards (graceful path)", async () => {
|
|
// Publish then submit so state=done
|
|
const board = await publishTestBoard();
|
|
await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
|
|
);
|
|
// The handler arms setTimeout(gracefulShutdown, 50), and gracefulShutdown
|
|
// arms setTimeout(process.exit, 50). bun test runs ALL files in one
|
|
// process, so letting that exit fire would kill the whole suite ~100ms
|
|
// later (exit 0, no summary — see test/no-suicide-exit.test.ts). Stub
|
|
// process.exit and restore it only after the exit callback has reached
|
|
// the stub; a fixed sleep lets a late timer reach the real exit under
|
|
// load. (resetForTest does NOT defuse the timers: the exit callback is
|
|
// unconditional.)
|
|
const origExit = process.exit;
|
|
let exitCode: number | undefined;
|
|
const exited = new Promise<void>((resolve) => {
|
|
(process as any).exit = ((code?: number) => {
|
|
exitCode = code;
|
|
resolve();
|
|
}) as any;
|
|
});
|
|
try {
|
|
const r = await fetchHandler(req("POST", "/shutdown"));
|
|
expect(r.status).toBe(200);
|
|
const body = (await r.json()) as any;
|
|
expect(body.shuttingDown).toBe(true);
|
|
await exited;
|
|
expect(exitCode).toBe(0);
|
|
} finally {
|
|
(process as any).exit = origExit;
|
|
}
|
|
// Reset state for subsequent tests (gracefulShutdown set shuttingDown).
|
|
resetDaemon();
|
|
});
|
|
});
|
|
|
|
// ─── LRU + non-done protection ───────────────────────────────────
|
|
|
|
describe("daemon LRU eviction", () => {
|
|
test("evicts done boards in preference to non-done", async () => {
|
|
// Seed the map directly so we don't have to publish 50 real boards.
|
|
// Setup: 10 done (oldest) + 40 serving (newer) = 50 total, 40 non-done.
|
|
// Publishing a 51st board: nonDoneCount(40) < MAX(50) → accepts, inserts,
|
|
// size=51, then evictUntilUnderCap kicks out the LRU done.
|
|
const boards = __testInternals__.boards;
|
|
const mk = (id: string, state: "serving" | "done", lastTouched: number) => {
|
|
boards.set(id, {
|
|
id,
|
|
htmlContent: "<p>seeded</p>",
|
|
sourceDir: `/tmp/seeded-${id}`,
|
|
allowedDir: `/tmp/seeded-${id}`,
|
|
state,
|
|
publishedAt: lastTouched,
|
|
lastTouched,
|
|
publisherPid: 0,
|
|
});
|
|
};
|
|
for (let i = 0; i < 10; i++) mk(`b-done-${i}`, "done", 1000 + i);
|
|
for (let i = 0; i < 40; i++) mk(`b-active-${i}`, "serving", 2000 + i);
|
|
expect(boards.size).toBe(50);
|
|
|
|
const htmlPath = makeBoardHtml(tmpDir);
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
|
|
expect(r.status).toBe(200);
|
|
|
|
expect(boards.size).toBeLessThanOrEqual(50);
|
|
// At least one of the (oldest) done boards is gone; non-done untouched.
|
|
let doneGoneCount = 0;
|
|
for (let i = 0; i < 10; i++) if (!boards.has(`b-done-${i}`)) doneGoneCount += 1;
|
|
expect(doneGoneCount).toBeGreaterThanOrEqual(1);
|
|
// All non-done preserved
|
|
for (let i = 0; i < 40; i++) {
|
|
expect(boards.has(`b-active-${i}`)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("503 when 50 non-done boards already exist", async () => {
|
|
const boards = __testInternals__.boards;
|
|
for (let i = 0; i < 50; i++) {
|
|
boards.set(`b-busy-${i}`, {
|
|
id: `b-busy-${i}`,
|
|
htmlContent: "<p>busy</p>",
|
|
sourceDir: `/tmp/busy-${i}`,
|
|
allowedDir: `/tmp/busy-${i}`,
|
|
state: "serving",
|
|
publishedAt: i,
|
|
lastTouched: i,
|
|
publisherPid: 0,
|
|
});
|
|
}
|
|
const htmlPath = makeBoardHtml(tmpDir);
|
|
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
|
|
expect(r.status).toBe(503);
|
|
});
|
|
});
|
|
|
|
// ─── Idle + meaningful activity ──────────────────────────────────
|
|
//
|
|
// The behavioral tests for idle shutdown — actual process exit, bare-GET-
|
|
// doesn't-reset-idle, MAX_EXTENSIONS hard ceiling — live in
|
|
// daemon-discovery.test.ts because they require a real spawned daemon
|
|
// (lastMeaningfulActivity isn't observable in-process). The in-process
|
|
// version of these tests previously was a smoke that the testing specialist
|
|
// correctly flagged as misleading; it was removed.
|
|
|
|
describe("daemon idle + activity tracking (smoke)", () => {
|
|
test("idleCheckTick on a freshly-touched daemon does not throw or shut down", () => {
|
|
markMeaningfulActivity();
|
|
expect(() => idleCheckTick()).not.toThrow();
|
|
// boards map shouldn't have been wiped (no graceful shutdown happened)
|
|
expect(typeof __testInternals__.boards.size).toBe("number");
|
|
});
|
|
});
|
|
|
|
// ─── Malformed body negatives ────────────────────────────────────
|
|
|
|
describe("daemon malformed body handling", () => {
|
|
test("POST /api/boards rejects invalid JSON body with 400", async () => {
|
|
const bad = new Request("http://127.0.0.1:1234/api/boards", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{not json",
|
|
});
|
|
const r = await fetchHandler(bad);
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("Invalid JSON");
|
|
});
|
|
|
|
test("POST /api/boards rejects non-object body (e.g. JSON null) with 400", async () => {
|
|
// JS quirk: `typeof [] === "object"`, so arrays slip past the
|
|
// !body || typeof body !== "object" guard and fail at the missing-html
|
|
// check below. The "Expected JSON object" path only fires for genuinely
|
|
// non-object values like null, numbers, strings.
|
|
const bad = new Request("http://127.0.0.1:1234/api/boards", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "null",
|
|
});
|
|
const r = await fetchHandler(bad);
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("Expected JSON object");
|
|
});
|
|
|
|
test("POST /api/boards: array body falls through to missing-html 400", async () => {
|
|
// Documents the actual behavior — arrays bypass the type guard but get
|
|
// caught by the html-field check. If we ever tighten the type check to
|
|
// reject arrays explicitly, this test will surface the change.
|
|
const r = await fetchHandler(req("POST", "/api/boards", [1, 2, 3] as any));
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("Missing 'html'");
|
|
});
|
|
|
|
test("POST /boards/<id>/api/reload rejects invalid JSON body with 400", async () => {
|
|
const board = await publishTestBoard();
|
|
const bad = new Request(
|
|
`http://127.0.0.1:1234/boards/${board.id}/api/reload`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{nope",
|
|
},
|
|
);
|
|
const r = await fetchHandler(bad);
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
test("POST /boards/<id>/api/reload rejects body missing html field with 400", async () => {
|
|
const board = await publishTestBoard();
|
|
const r = await fetchHandler(
|
|
req("POST", `/boards/${board.id}/api/reload`, { somethingElse: true }),
|
|
);
|
|
expect(r.status).toBe(400);
|
|
const body = (await r.json()) as any;
|
|
expect(body.error).toContain("HTML file not found");
|
|
});
|
|
});
|
|
|
|
// ─── Daemon log ──────────────────────────────────────────────────
|
|
|
|
describe("daemon log", () => {
|
|
test("an unopenable log disables file logging instead of raising an uncaught error", async () => {
|
|
// A directory where the log file belongs makes the asynchronous open fail
|
|
// with EISDIR, the same path as a state root removed before the open lands.
|
|
fs.mkdirSync(path.join(stateRoot.dir, "design-daemon.log"));
|
|
await publishTestBoard();
|
|
const log = __testInternals__.daemonLog();
|
|
expect(log).toBeTruthy();
|
|
while (__testInternals__.daemonLog() === log) await new Promise((r) => setTimeout(r, 1));
|
|
expect(log!.destroyed).toBe(true);
|
|
expect(__testInternals__.daemonLog()).toBeNull();
|
|
|
|
const dirB = makeTmpDir("log-b");
|
|
try {
|
|
await publishTestBoard({ dir: dirB });
|
|
expect(__testInternals__.boards.size).toBe(2);
|
|
} finally {
|
|
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Unknown routes ──────────────────────────────────────────────
|
|
|
|
describe("daemon unknown routes", () => {
|
|
test("404 on unknown path", async () => {
|
|
const r = await fetchHandler(req("GET", "/some/unknown/path"));
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
test("GET /api/boards (wrong method on publish endpoint) returns 404", async () => {
|
|
const r = await fetchHandler(req("GET", "/api/boards"));
|
|
expect(r.status).toBe(404);
|
|
});
|
|
});
|