chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for $D OpenAI auth source reporting (#1278, closes #1248).
|
||||
*
|
||||
* Verifies that resolveApiKey + requireApiKey:
|
||||
* - prefer ~/.gstack/openai.json over OPENAI_API_KEY
|
||||
* - report when the env-var key matches a cwd .env / .env.local
|
||||
* - never echo the key itself to stderr (only the source label)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import {
|
||||
describeApiKeySource,
|
||||
requireApiKey,
|
||||
resolveApiKey,
|
||||
resolveApiKeyInfo,
|
||||
saveApiKey,
|
||||
} from "../src/auth";
|
||||
|
||||
let tmpDir: string;
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalKey: string | undefined;
|
||||
let originalNodeEnv: string | undefined;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-auth-"));
|
||||
tmpHome = path.join(tmpDir, "home");
|
||||
fs.mkdirSync(tmpHome, { recursive: true });
|
||||
|
||||
originalHome = process.env.HOME;
|
||||
originalKey = process.env.OPENAI_API_KEY;
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
originalCwd = process.cwd();
|
||||
|
||||
process.env.HOME = tmpHome;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.NODE_ENV;
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = originalKey;
|
||||
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalNodeEnv;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveApiKeyInfo", () => {
|
||||
test("uses ~/.gstack/openai.json before OPENAI_API_KEY", () => {
|
||||
saveApiKey("sk-config");
|
||||
process.env.OPENAI_API_KEY = "sk-env";
|
||||
|
||||
const resolution = resolveApiKeyInfo();
|
||||
|
||||
expect(resolution?.key).toBe("sk-config");
|
||||
expect(resolution?.source).toBe("config");
|
||||
expect(describeApiKeySource(resolution!)).toBe("~/.gstack/openai.json");
|
||||
expect(resolveApiKey()).toBe("sk-config");
|
||||
});
|
||||
|
||||
test("uses OPENAI_API_KEY when no config file exists", () => {
|
||||
process.env.OPENAI_API_KEY = "sk-env";
|
||||
|
||||
const resolution = resolveApiKeyInfo();
|
||||
|
||||
expect(resolution?.key).toBe("sk-env");
|
||||
expect(resolution?.source).toBe("env");
|
||||
expect(resolution?.envFile).toBeUndefined();
|
||||
expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable");
|
||||
});
|
||||
|
||||
test("reports when OPENAI_API_KEY matches current-directory .env", () => {
|
||||
fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-project\n");
|
||||
process.env.OPENAI_API_KEY = "sk-project";
|
||||
|
||||
const resolution = resolveApiKeyInfo();
|
||||
|
||||
expect(resolution?.key).toBe("sk-project");
|
||||
expect(resolution?.envFile).toBe(".env");
|
||||
expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable (matches .env in current directory)");
|
||||
expect(resolution?.warning).toContain("may bill that project's OpenAI account");
|
||||
});
|
||||
|
||||
test("detects quoted and exported env-file values", () => {
|
||||
fs.writeFileSync(path.join(tmpDir, ".env.local"), "export OPENAI_API_KEY=\"sk-local\"\n");
|
||||
process.env.OPENAI_API_KEY = "sk-local";
|
||||
|
||||
const resolution = resolveApiKeyInfo();
|
||||
|
||||
expect(resolution?.envFile).toBe(".env.local");
|
||||
expect(resolution?.warning).toContain(".env.local");
|
||||
});
|
||||
|
||||
test("does not claim env-file source when values differ", () => {
|
||||
fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-other\n");
|
||||
process.env.OPENAI_API_KEY = "sk-shell";
|
||||
|
||||
const resolution = resolveApiKeyInfo();
|
||||
|
||||
expect(resolution?.key).toBe("sk-shell");
|
||||
expect(resolution?.envFile).toBeUndefined();
|
||||
expect(resolution?.warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireApiKey", () => {
|
||||
test("prints source disclosure without leaking the key", () => {
|
||||
process.env.OPENAI_API_KEY = "sk-secret-value";
|
||||
const messages: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
expect(requireApiKey()).toBe("sk-secret-value");
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
const stderr = messages.join("\n");
|
||||
expect(stderr).toContain("Using OpenAI key from OPENAI_API_KEY environment variable.");
|
||||
expect(stderr).not.toContain("sk-secret-value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Out-of-process tests for daemon-client.ts.
|
||||
*
|
||||
* Spawns real daemon subprocesses (via the fixtures helper) so we can
|
||||
* exercise: state-file discovery, /health attach vs spawn, the lock +
|
||||
* re-read-under-lock race, identity-verified SIGTERM, version mismatch
|
||||
* with and without active boards, startup-error log surfacing, and the
|
||||
* concurrent-CLIs race (two real subprocesses, one wins the lock).
|
||||
*
|
||||
* These tests are slower than daemon.test.ts (each spawn is ~200ms) so
|
||||
* they're kept in a separate file to keep the in-process suite fast.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import {
|
||||
daemonStatus,
|
||||
ensureDaemon,
|
||||
publishBoard,
|
||||
shutdownDaemon,
|
||||
} from "../src/daemon-client";
|
||||
import {
|
||||
acquireLock,
|
||||
CMDLINE_MARKER,
|
||||
isProcessAlive,
|
||||
readStateFile,
|
||||
resolveLockFilePath,
|
||||
verifyIdentity,
|
||||
} from "../src/daemon-state";
|
||||
import {
|
||||
DAEMON_SCRIPT,
|
||||
makeBoardHtml,
|
||||
makeTmpDir,
|
||||
spawnDaemonForTest,
|
||||
type SpawnedDaemon,
|
||||
} from "./daemon-tests-fixtures";
|
||||
|
||||
let workDir: string;
|
||||
let stateFile: string;
|
||||
let activeDaemons: SpawnedDaemon[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = makeTmpDir("discovery");
|
||||
stateFile = path.join(workDir, "design.json");
|
||||
// Each test gets a private state-file path; env var ensures both the
|
||||
// client's resolver and any spawned daemons converge on the same file.
|
||||
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const d of activeDaemons.splice(0)) {
|
||||
try { await d.stop(); } catch {}
|
||||
}
|
||||
// Tear down any state file left around so the next test starts clean.
|
||||
try { fs.unlinkSync(stateFile); } catch {}
|
||||
try { fs.unlinkSync(resolveLockFilePath(stateFile)); } catch {}
|
||||
delete process.env.DESIGN_DAEMON_STATE_FILE;
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
async function spawn1(idleMs = 60_000): Promise<SpawnedDaemon> {
|
||||
const d = await spawnDaemonForTest({ stateFile, idleMs });
|
||||
activeDaemons.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── healthCheck + readStateFile basics ──────────────────────────
|
||||
|
||||
describe("daemon-state helpers", () => {
|
||||
test("readStateFile returns null when missing", () => {
|
||||
expect(readStateFile(stateFile)).toBeNull();
|
||||
});
|
||||
|
||||
test("spawned daemon writes a usable state file", async () => {
|
||||
const d = await spawn1();
|
||||
const state = readStateFile(stateFile);
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.pid).toBe(d.proc.pid);
|
||||
expect(state!.port).toBe(d.port);
|
||||
expect(state!.cmdlineMarker).toBe(CMDLINE_MARKER);
|
||||
expect(state!.version).toBe("test-version");
|
||||
});
|
||||
|
||||
test("verifyIdentity matches a real spawned daemon's cmdline", async () => {
|
||||
const d = await spawn1();
|
||||
expect(verifyIdentity(d.proc.pid!, CMDLINE_MARKER)).toBe(true);
|
||||
// wrong marker → false
|
||||
expect(verifyIdentity(d.proc.pid!, "some-other-marker-xyz")).toBe(false);
|
||||
});
|
||||
|
||||
test("verifyIdentity returns false for dead pids", async () => {
|
||||
expect(verifyIdentity(999_999_999, CMDLINE_MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ensureDaemon ────────────────────────────────────────────────
|
||||
|
||||
describe("ensureDaemon", () => {
|
||||
test("with no state file: spawns a fresh daemon", async () => {
|
||||
const result = await ensureDaemon({
|
||||
version: "test-version",
|
||||
stateFile,
|
||||
verbose: false,
|
||||
});
|
||||
expect(result.spawned).toBe(true);
|
||||
expect(result.port).toBeGreaterThan(0);
|
||||
expect(result.version).toBe("test-version");
|
||||
|
||||
const state = readStateFile(stateFile);
|
||||
expect(state).not.toBeNull();
|
||||
expect(isProcessAlive(state!.pid)).toBe(true);
|
||||
|
||||
// Track for cleanup
|
||||
activeDaemons.push({
|
||||
proc: { pid: state!.pid } as any,
|
||||
port: state!.port,
|
||||
stateFile,
|
||||
stop: async () => {
|
||||
try { process.kill(state!.pid, "SIGTERM"); } catch {}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("with a healthy daemon already running: attaches without spawning", async () => {
|
||||
const existing = await spawn1();
|
||||
const result = await ensureDaemon({
|
||||
version: "test-version",
|
||||
stateFile,
|
||||
verbose: false,
|
||||
});
|
||||
expect(result.spawned).toBe(false);
|
||||
expect(result.port).toBe(existing.port);
|
||||
});
|
||||
|
||||
test("with a stale state file (PID dead): spawns fresh, overwrites state", async () => {
|
||||
// Synthesize a stale state file pointing at a definitely-dead pid.
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: 999_999_998,
|
||||
port: 1, // bogus port — /health will fail fast
|
||||
startedAt: "2020-01-01T00:00:00Z",
|
||||
version: "ancient",
|
||||
serverPath: "/nope",
|
||||
cmdlineMarker: CMDLINE_MARKER,
|
||||
}));
|
||||
|
||||
const result = await ensureDaemon({
|
||||
version: "test-version",
|
||||
stateFile,
|
||||
verbose: false,
|
||||
});
|
||||
expect(result.spawned).toBe(true);
|
||||
|
||||
// State file should now point at the live daemon.
|
||||
const fresh = readStateFile(stateFile);
|
||||
expect(fresh!.pid).not.toBe(999_999_998);
|
||||
expect(isProcessAlive(fresh!.pid)).toBe(true);
|
||||
|
||||
activeDaemons.push({
|
||||
proc: { pid: fresh!.pid } as any,
|
||||
port: fresh!.port,
|
||||
stateFile,
|
||||
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||
});
|
||||
});
|
||||
|
||||
test("PID-reuse safety: stale state with an unrelated alive PID → identity-verify blocks signal, daemon spawned", async () => {
|
||||
// Use the current test process's PID — definitely alive, definitely
|
||||
// does NOT have CMDLINE_MARKER in its cmdline (it's the Bun test runner).
|
||||
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
pid: process.pid, // alive but NOT a daemon
|
||||
port: 1,
|
||||
startedAt: "2020-01-01T00:00:00Z",
|
||||
version: "ancient",
|
||||
serverPath: "/nope",
|
||||
cmdlineMarker: CMDLINE_MARKER,
|
||||
}));
|
||||
|
||||
// ensureDaemon should NOT signal process.pid (we'd kill ourselves);
|
||||
// verifyIdentity catches the cmdline mismatch and skips the kill.
|
||||
const result = await ensureDaemon({
|
||||
version: "test-version",
|
||||
stateFile,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
// We're still alive (didn't get killed)
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
expect(result.spawned).toBe(true);
|
||||
|
||||
const fresh = readStateFile(stateFile);
|
||||
expect(fresh!.pid).not.toBe(process.pid);
|
||||
activeDaemons.push({
|
||||
proc: { pid: fresh!.pid } as any,
|
||||
port: fresh!.port,
|
||||
stateFile,
|
||||
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||
});
|
||||
});
|
||||
|
||||
test("version mismatch with NO active boards: gracefully shuts existing down and respawns", async () => {
|
||||
const existing = await spawn1();
|
||||
// The existing daemon's version is "test-version" (set by fixture env).
|
||||
// ensureDaemon with a DIFFERENT version → should /shutdown the existing
|
||||
// (no active boards) and spawn fresh.
|
||||
const result = await ensureDaemon({
|
||||
version: "different-version",
|
||||
stateFile,
|
||||
verbose: false,
|
||||
});
|
||||
expect(result.spawned).toBe(true);
|
||||
expect(result.version).toBe("different-version");
|
||||
|
||||
// existing.proc.pid should be gone by now (or soon)
|
||||
// Give it a moment for the /shutdown + SIGTERM to take effect
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(isProcessAlive(existing.proc.pid!)).toBe(false);
|
||||
|
||||
// New daemon recorded
|
||||
const fresh = readStateFile(stateFile);
|
||||
expect(fresh!.pid).not.toBe(existing.proc.pid);
|
||||
activeDaemons.push({
|
||||
proc: { pid: fresh!.pid } as any,
|
||||
port: fresh!.port,
|
||||
stateFile,
|
||||
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||
});
|
||||
});
|
||||
|
||||
test("version mismatch WITH active boards: refuses to kill, exits 1 with user-actionable error", async () => {
|
||||
// Run the ensureDaemon-that-would-exit-1 in a subprocess so we can
|
||||
// observe the exit code and stderr without killing the test runner.
|
||||
const existing = await spawn1();
|
||||
|
||||
// Publish a board so activeBoards > 0
|
||||
const html = makeBoardHtml(workDir);
|
||||
await publishBoard({ port: existing.port, html });
|
||||
|
||||
// Sanity: status should reflect the active board
|
||||
const statusResp = await fetch(`http://127.0.0.1:${existing.port}/health`);
|
||||
const status = (await statusResp.json()) as any;
|
||||
expect(status.activeBoards).toBe(1);
|
||||
|
||||
// Now run a tiny script that calls ensureDaemon with a mismatched
|
||||
// version. It should print the WARNING + exit 1.
|
||||
const scriptPath = path.join(workDir, "ensure-mismatch.ts");
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import { ensureDaemon } from "${path.resolve(import.meta.dir, "..", "src", "daemon-client.ts").replace(/\\\\/g, "/")}";
|
||||
await ensureDaemon({
|
||||
version: "totally-different-version",
|
||||
stateFile: ${JSON.stringify(stateFile)},
|
||||
verbose: true,
|
||||
});
|
||||
console.log("REACHED_AFTER_ENSURE — should not happen");
|
||||
`);
|
||||
|
||||
const child = spawn("bun", ["run", scriptPath], {
|
||||
env: { ...process.env, DESIGN_DAEMON_STATE_FILE: stateFile },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
child.stderr.on("data", (c) => stderrChunks.push(c));
|
||||
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
child.on("exit", (code) => resolve(code ?? -1));
|
||||
});
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
const stdout = Buffer.concat(stdoutChunks).toString();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("active board");
|
||||
expect(stderr).toContain("Refusing to auto-kill");
|
||||
// We must NOT have reached the post-ensure line
|
||||
expect(stdout).not.toContain("REACHED_AFTER_ENSURE");
|
||||
|
||||
// And the existing daemon should still be alive
|
||||
expect(isProcessAlive(existing.proc.pid!)).toBe(true);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ─── publishBoard ────────────────────────────────────────────────
|
||||
|
||||
describe("publishBoard", () => {
|
||||
test("publishes a board through the real HTTP path and returns id+url+sourceDir", async () => {
|
||||
const d = await spawn1();
|
||||
const htmlPath = makeBoardHtml(workDir, "<p>via-client</p>");
|
||||
const result = await publishBoard({ port: d.port, html: htmlPath });
|
||||
expect(result.id).toMatch(/^b-/);
|
||||
expect(result.url).toBe(`http://127.0.0.1:${d.port}/boards/${result.id}/`);
|
||||
expect(result.sourceDir).toBe(fs.realpathSync(workDir));
|
||||
|
||||
// Confirm the board is actually fetchable at the returned URL
|
||||
const r = await fetch(result.url);
|
||||
expect(r.status).toBe(200);
|
||||
const html = await r.text();
|
||||
expect(html).toContain("via-client");
|
||||
});
|
||||
|
||||
test("409 surfaces existing board's id+url (returned object, no throw)", async () => {
|
||||
const d = await spawn1();
|
||||
const htmlPath = makeBoardHtml(workDir);
|
||||
const first = await publishBoard({ port: d.port, html: htmlPath });
|
||||
const htmlPath2 = makeBoardHtml(workDir, "<p>second</p>");
|
||||
const second = await publishBoard({ port: d.port, html: htmlPath2 });
|
||||
// Same sourceDir → 409 with `existing` field; publishBoard returns it
|
||||
// so the caller can attach to the existing board.
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.url).toBe(first.url);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shutdownDaemon / daemonStatus ───────────────────────────────
|
||||
|
||||
describe("shutdownDaemon + daemonStatus", () => {
|
||||
test("status reports not-running when no state file", async () => {
|
||||
const s = await daemonStatus();
|
||||
expect(s.running).toBe(false);
|
||||
});
|
||||
|
||||
test("status reports running with port + version + counts when daemon alive", async () => {
|
||||
const d = await spawn1();
|
||||
const s = await daemonStatus();
|
||||
expect(s.running).toBe(true);
|
||||
if (s.running) {
|
||||
expect(s.port).toBe(d.port);
|
||||
expect(s.pid).toBe(d.proc.pid);
|
||||
expect(s.version).toBe("test-version");
|
||||
expect(s.boards).toBe(0);
|
||||
expect(s.activeBoards).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("shutdownDaemon succeeds when no active boards", async () => {
|
||||
const d = await spawn1();
|
||||
const r = await shutdownDaemon();
|
||||
expect(r.stopped).toBe(true);
|
||||
// Give it a moment to die
|
||||
await new Promise((res) => setTimeout(res, 300));
|
||||
expect(isProcessAlive(d.proc.pid!)).toBe(false);
|
||||
});
|
||||
|
||||
test("shutdownDaemon refuses (without force) when active boards present", async () => {
|
||||
const d = await spawn1();
|
||||
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
|
||||
const r = await shutdownDaemon();
|
||||
expect(r.stopped).toBe(false);
|
||||
expect(r.reason).toContain("active");
|
||||
expect(r.activeBoards).toBe(1);
|
||||
// Daemon still running
|
||||
expect(isProcessAlive(d.proc.pid!)).toBe(true);
|
||||
});
|
||||
|
||||
test("shutdownDaemon with force=true ignores active boards", async () => {
|
||||
const d = await spawn1();
|
||||
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
|
||||
const r = await shutdownDaemon({ force: true });
|
||||
expect(r.stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real idle-shutdown behavior (spawned daemon, fast clock) ───
|
||||
//
|
||||
// The lastMeaningfulActivity timestamp is not observable from outside the
|
||||
// daemon process, so the only way to prove "bare GETs do not reset the
|
||||
// idle timer" is to spawn a real daemon with a short idle window, hit
|
||||
// progress polls in a loop, and watch the process exit anyway.
|
||||
//
|
||||
// These tests aim for ~3-5s real time per test by setting IDLE_MS=2000
|
||||
// and CHECK_MS=200. The idle-with-active-boards extension path needs a
|
||||
// board in `serving` state to exercise.
|
||||
|
||||
describe("daemon idle-shutdown behavior (real process)", () => {
|
||||
// Wait for a child process to exit, with a deadline. Resolves true on
|
||||
// observed exit, false on timeout. Doesn't kill on timeout — caller does.
|
||||
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(pid)) return true;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test("idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS", async () => {
|
||||
const d = await spawnDaemonForTest({
|
||||
stateFile,
|
||||
idleMs: 2_000,
|
||||
checkMs: 200,
|
||||
});
|
||||
// Don't push to activeDaemons; the daemon should self-exit and the
|
||||
// afterEach SIGTERM would race with that. Track manually.
|
||||
try {
|
||||
// No boards published. lastMeaningfulActivity is the startup time.
|
||||
// Wait IDLE_MS + a couple CHECK_MS intervals for the timer to fire.
|
||||
const exited = await waitForExit(d.proc.pid!, 5_000);
|
||||
expect(exited).toBe(true);
|
||||
// State file removed by gracefulShutdown
|
||||
expect(readStateFile(stateFile)).toBeNull();
|
||||
} finally {
|
||||
if (isProcessAlive(d.proc.pid!)) {
|
||||
try { d.proc.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
test("bare GET polling does NOT prevent idle shutdown (progress polls don't reset idle)", async () => {
|
||||
const d = await spawnDaemonForTest({
|
||||
stateFile,
|
||||
idleMs: 2_000,
|
||||
checkMs: 200,
|
||||
});
|
||||
let polling = true;
|
||||
let pollCount = 0;
|
||||
const boardDir = makeTmpDir("idle-poll");
|
||||
try {
|
||||
const board = await publishBoard({
|
||||
port: d.port,
|
||||
html: makeBoardHtml(boardDir),
|
||||
});
|
||||
// Submit so the board becomes `done` — non-done would trigger the
|
||||
// 1h extension path and keep the daemon alive past IDLE_MS.
|
||||
await fetch(`${board.url}api/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ regenerated: false, preferred: "A" }),
|
||||
});
|
||||
// Hammer /api/progress every 200ms in the background. If bare GETs
|
||||
// reset meaningful activity, the daemon would never idle out.
|
||||
const pollLoop = (async () => {
|
||||
while (polling) {
|
||||
try {
|
||||
await fetch(`${board.url}api/progress`);
|
||||
pollCount += 1;
|
||||
} catch {
|
||||
// daemon went away
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
})();
|
||||
|
||||
const exited = await waitForExit(d.proc.pid!, 6_000);
|
||||
polling = false;
|
||||
await pollLoop;
|
||||
|
||||
expect(exited).toBe(true);
|
||||
// We polled at least a few times before the daemon idled out
|
||||
expect(pollCount).toBeGreaterThan(3);
|
||||
expect(readStateFile(stateFile)).toBeNull();
|
||||
} finally {
|
||||
polling = false;
|
||||
if (isProcessAlive(d.proc.pid!)) {
|
||||
try { d.proc.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("idle with active (non-done) boards triggers extension instead of shutdown", async () => {
|
||||
// With non-done boards, the daemon should NOT shut down on the first
|
||||
// idle check after IDLE_MS — it extends. Verify it's still alive past
|
||||
// the would-be-shutdown deadline. The MAX_EXTENSIONS=4 hard ceiling
|
||||
// would take 4 * 1h = 4h to exercise with default extension window,
|
||||
// so we shrink both IDLE and EXTENSION via env to test it in seconds.
|
||||
const d = await spawnDaemonForTest({
|
||||
stateFile,
|
||||
idleMs: 1_500,
|
||||
checkMs: 200,
|
||||
env: {
|
||||
DESIGN_DAEMON_EXTENSION_MS: "1500",
|
||||
DESIGN_DAEMON_MAX_EXTENSIONS: "2",
|
||||
},
|
||||
});
|
||||
const boardDir = makeTmpDir("idle-active");
|
||||
try {
|
||||
await publishBoard({ port: d.port, html: makeBoardHtml(boardDir) });
|
||||
// Daemon has 1 non-done board. After IDLE_MS, idleCheckTick should
|
||||
// extend rather than shut down. So at IDLE_MS + small margin, it's
|
||||
// still alive.
|
||||
await new Promise((r) => setTimeout(r, 2_500));
|
||||
expect(isProcessAlive(d.proc.pid!)).toBe(true);
|
||||
expect(readStateFile(stateFile)).not.toBeNull();
|
||||
|
||||
// After MAX_EXTENSIONS extension windows (2 * 1500ms = 3000ms more),
|
||||
// the hard ceiling kicks in and force-shutdown fires. Total wait:
|
||||
// IDLE_MS(1500) + EXT*MAX(3000) + slack(1000) = ~5500ms. We've already
|
||||
// waited 2500ms, so 4000ms more.
|
||||
const exited = await waitForExit(d.proc.pid!, 5_500);
|
||||
expect(exited).toBe(true);
|
||||
expect(readStateFile(stateFile)).toBeNull();
|
||||
} finally {
|
||||
if (isProcessAlive(d.proc.pid!)) {
|
||||
try { d.proc.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ─── Concurrent ensureDaemon race (one wins the lock) ───────────
|
||||
|
||||
describe("concurrent ensureDaemon race", () => {
|
||||
test("two parallel ensureDaemon() calls converge on one daemon (one spawned, one attached)", async () => {
|
||||
// Fire two ensureDaemon calls in parallel against the same empty
|
||||
// stateFile. The fs.openSync('wx') lock should make exactly one win
|
||||
// the spawn race; the loser waits for the first to write the state
|
||||
// file, then attaches.
|
||||
const [a, b] = await Promise.all([
|
||||
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
|
||||
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
|
||||
]);
|
||||
|
||||
// Both got the same port (same daemon)
|
||||
expect(a.port).toBe(b.port);
|
||||
|
||||
// Exactly one spawned, one attached
|
||||
const spawnedCount = [a.spawned, b.spawned].filter(Boolean).length;
|
||||
expect(spawnedCount).toBe(1);
|
||||
|
||||
// Exactly one daemon process is alive at that port
|
||||
const state = readStateFile(stateFile);
|
||||
expect(state).not.toBeNull();
|
||||
expect(isProcessAlive(state!.pid)).toBe(true);
|
||||
|
||||
// Lock file cleaned up (the winner released it on exit from the try block)
|
||||
expect(fs.existsSync(resolveLockFilePath(stateFile))).toBe(false);
|
||||
|
||||
// Track for cleanup
|
||||
activeDaemons.push({
|
||||
proc: { pid: state!.pid } as any,
|
||||
port: state!.port,
|
||||
stateFile,
|
||||
stop: async () => {
|
||||
try { process.kill(state!.pid, "SIGTERM"); } catch {}
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ─── Stale-lock reclaim ──────────────────────────────────────────
|
||||
|
||||
describe("acquireLock stale-lock reclaim", () => {
|
||||
test("reclaims a lockfile owned by a dead PID and writes our PID", () => {
|
||||
const lockPath = resolveLockFilePath(stateFile);
|
||||
// Plant a lockfile owned by a definitely-dead PID
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
fs.writeFileSync(lockPath, "999999998\n");
|
||||
|
||||
const release = acquireLock(lockPath);
|
||||
expect(release).not.toBeNull();
|
||||
// Lock file now contains our PID
|
||||
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
|
||||
|
||||
release!();
|
||||
// Released = lock file gone
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("refuses to reclaim a lockfile owned by an alive (unrelated) PID", () => {
|
||||
const lockPath = resolveLockFilePath(stateFile);
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
// Use this test process's own PID — it's alive AND unrelated to a daemon.
|
||||
// acquireLock should refuse and return null without unlinking the lock.
|
||||
fs.writeFileSync(lockPath, `${process.pid}\n`);
|
||||
|
||||
const release = acquireLock(lockPath);
|
||||
expect(release).toBeNull();
|
||||
// Lock file is untouched
|
||||
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
|
||||
|
||||
// Cleanup
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Shared helpers for daemon + daemon-client tests.
|
||||
*
|
||||
* Two test styles live here:
|
||||
* - In-process: import fetchHandler from daemon.ts and call it with a
|
||||
* synthesized Request. Fast, no spawn, no HTTP. Covers routing +
|
||||
* handler semantics. Used by most of daemon.test.ts.
|
||||
* - Out-of-process: spawn `bun run design/src/daemon.ts` with a tmp
|
||||
* state file + env overrides, then HTTP against the bound port.
|
||||
* Slow but only path that proves real spawn + state file + signal
|
||||
* handling work. Used by daemon-discovery.test.ts.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import { __testInternals__ } from "../src/daemon";
|
||||
|
||||
export const DAEMON_SCRIPT = path.join(import.meta.dir, "..", "src", "daemon.ts");
|
||||
|
||||
export function makeTmpDir(prefix = "design-daemon-test"): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
|
||||
}
|
||||
|
||||
export function makeBoardHtml(tmpDir: string, body = "<p>Test board</p>"): string {
|
||||
const p = path.join(tmpDir, "design-board.html");
|
||||
fs.writeFileSync(
|
||||
p,
|
||||
`<!DOCTYPE html><html><head></head><body>${body}</body></html>`,
|
||||
);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Reset the in-process daemon state between tests. */
|
||||
export function resetDaemon(): void {
|
||||
__testInternals__.resetForTest();
|
||||
}
|
||||
|
||||
/** Build a Request for the in-process fetchHandler tests. */
|
||||
export function req(method: string, urlPath: string, body?: unknown): Request {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined) {
|
||||
init.body = typeof body === "string" ? body : JSON.stringify(body);
|
||||
init.headers = { "Content-Type": "application/json" };
|
||||
}
|
||||
return new Request(`http://127.0.0.1:1234${urlPath}`, init);
|
||||
}
|
||||
|
||||
export interface SpawnedDaemon {
|
||||
proc: ChildProcess;
|
||||
port: number;
|
||||
stateFile: string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a real daemon process pointed at a per-test state file, with an
|
||||
* aggressive idle window so idle-shutdown tests don't take 24h. Resolves
|
||||
* when stdout emits `DAEMON_STARTED port=<N>`.
|
||||
*/
|
||||
export async function spawnDaemonForTest(
|
||||
opts: { stateFile?: string; idleMs?: number; checkMs?: number; env?: Record<string, string> } = {},
|
||||
): Promise<SpawnedDaemon> {
|
||||
const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json");
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
// DESIGN_DAEMON_STATE_FILE points both daemon and any same-process
|
||||
// discovery at this test's state file (overrides resolveStateFilePath).
|
||||
DESIGN_DAEMON_STATE_FILE: stateFile,
|
||||
DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000),
|
||||
DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000),
|
||||
DESIGN_DAEMON_VERSION: "test-version",
|
||||
...(opts.env ?? {}),
|
||||
};
|
||||
|
||||
// Spawn with a marker in argv so cmdline-based identity verification
|
||||
// exercises the real CMDLINE_MARKER ("gstack-design-daemon").
|
||||
const proc = spawn(
|
||||
"bun",
|
||||
["run", DAEMON_SCRIPT, "--marker", "gstack-design-daemon"],
|
||||
{
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: path.dirname(stateFile),
|
||||
},
|
||||
);
|
||||
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const onTimeout = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
reject(new Error("Daemon failed to emit DAEMON_STARTED within 5s"));
|
||||
}, 5000);
|
||||
proc.stdout!.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString();
|
||||
const m = line.match(/DAEMON_STARTED port=(\d+)/);
|
||||
if (m) {
|
||||
clearTimeout(onTimeout);
|
||||
resolve(parseInt(m[1]!, 10));
|
||||
}
|
||||
});
|
||||
proc.on("error", (e) => {
|
||||
clearTimeout(onTimeout);
|
||||
reject(e);
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
clearTimeout(onTimeout);
|
||||
reject(new Error(`Daemon exited early with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
proc,
|
||||
port,
|
||||
stateFile,
|
||||
stop: async () => {
|
||||
proc.kill("SIGTERM");
|
||||
await new Promise<void>((r) => {
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// gone
|
||||
}
|
||||
r();
|
||||
}, 2000);
|
||||
proc.on("exit", () => {
|
||||
clearTimeout(t);
|
||||
r();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 }),
|
||||
);
|
||||
// Now non-done count is 0 — handler should return shuttingDown:true.
|
||||
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
|
||||
// after 50ms which would tear down the test runner); instead we just
|
||||
// observe the immediate response.
|
||||
const r = await fetchHandler(req("POST", "/shutdown"));
|
||||
expect(r.status).toBe(200);
|
||||
const body = (await r.json()) as any;
|
||||
expect(body.shuttingDown).toBe(true);
|
||||
// Reset state for subsequent tests; the shutdown timer will be a no-op
|
||||
// because the next resetForTest flips shuttingDown back to false.
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* End-to-end daemon round-trip test.
|
||||
*
|
||||
* Spawns a real design daemon and walks the full publish → submit /
|
||||
* regenerate / reload cycle via HTTP fetch (the same calls the board JS
|
||||
* makes). Proves what design-shotgun and the rest of the design skills
|
||||
* depend on:
|
||||
*
|
||||
* - $D compare --serve attaches to OR spawns a single shared daemon.
|
||||
* - Two boards published into the same daemon get independent paths
|
||||
* under /boards/<id>/ — no port churn, no second process.
|
||||
* - Submit writes feedback.json into the board's sourceDir with
|
||||
* boardId + publishedAt fields the skill can poll for.
|
||||
* - Regenerate writes feedback-pending.json, flips state to
|
||||
* regenerating, /api/progress reflects it.
|
||||
* - /api/reload swaps HTML in place — second GET returns new content.
|
||||
* - Even with two concurrent boards in flight, feedback for one does
|
||||
* not contaminate the other's sourceDir.
|
||||
*
|
||||
* Browser-driven round-trip (feedback-roundtrip.test.ts) covers the same
|
||||
* flow at the click level for the legacy --no-daemon path; this file is
|
||||
* the daemon-path equivalent.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { publishBoard } from "../src/daemon-client";
|
||||
import { readStateFile } from "../src/daemon-state";
|
||||
import {
|
||||
makeBoardHtml,
|
||||
makeTmpDir,
|
||||
spawnDaemonForTest,
|
||||
type SpawnedDaemon,
|
||||
} from "./daemon-tests-fixtures";
|
||||
|
||||
let workDir: string;
|
||||
let stateFile: string;
|
||||
let daemons: SpawnedDaemon[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = makeTmpDir("roundtrip-daemon");
|
||||
stateFile = path.join(workDir, "design.json");
|
||||
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const d of daemons.splice(0)) {
|
||||
try { await d.stop(); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(stateFile); } catch {}
|
||||
delete process.env.DESIGN_DAEMON_STATE_FILE;
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
async function spawn1(): Promise<SpawnedDaemon> {
|
||||
const d = await spawnDaemonForTest({ stateFile, idleMs: 60_000 });
|
||||
daemons.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── Submit round-trip ───────────────────────────────────────────
|
||||
|
||||
describe("daemon round-trip: publish → submit → feedback.json", () => {
|
||||
test("Submit feedback lands at sourceDir with boardId + publishedAt", async () => {
|
||||
const d = await spawn1();
|
||||
const boardDir = makeTmpDir("board-submit");
|
||||
try {
|
||||
const htmlPath = makeBoardHtml(boardDir, "<p>round-trip board</p>");
|
||||
const board = await publishBoard({ port: d.port, html: htmlPath });
|
||||
expect(board.url).toBe(`http://127.0.0.1:${d.port}/boards/${board.id}/`);
|
||||
expect(board.sourceDir).toBe(fs.realpathSync(boardDir));
|
||||
|
||||
// GET the board URL — same path the browser would hit
|
||||
const page = await fetch(board.url);
|
||||
expect(page.status).toBe(200);
|
||||
const pageHtml = await page.text();
|
||||
expect(pageHtml).toContain("round-trip board");
|
||||
|
||||
// POST submit (mirrors what the board JS does on Submit click)
|
||||
const submit = await fetch(`${board.url}api/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
preferred: "A",
|
||||
ratings: { A: 5, B: 3 },
|
||||
comments: { A: "love it" },
|
||||
overall: "ship A",
|
||||
regenerated: false,
|
||||
}),
|
||||
});
|
||||
expect(submit.status).toBe(200);
|
||||
const submitBody = (await submit.json()) as any;
|
||||
expect(submitBody.action).toBe("submitted");
|
||||
|
||||
// The skill side polls for feedback.json in the source directory
|
||||
const feedbackPath = path.join(board.sourceDir, "feedback.json");
|
||||
expect(fs.existsSync(feedbackPath)).toBe(true);
|
||||
const written = JSON.parse(fs.readFileSync(feedbackPath, "utf-8"));
|
||||
expect(written.preferred).toBe("A");
|
||||
expect(written.ratings).toEqual({ A: 5, B: 3 });
|
||||
expect(written.regenerated).toBe(false);
|
||||
// Augmented fields the daemon adds
|
||||
expect(written.boardId).toBe(board.id);
|
||||
expect(typeof written.publishedAt).toBe("string");
|
||||
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
// The board's URL stays accessible after submit (history view)
|
||||
const after = await fetch(board.url);
|
||||
expect(after.status).toBe(200);
|
||||
|
||||
// Progress endpoint reflects done state
|
||||
const progress = await fetch(`${board.url}api/progress`);
|
||||
expect(((await progress.json()) as any).status).toBe("done");
|
||||
} finally {
|
||||
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /boards/<id> (no trailing slash) returns 301 to /boards/<id>/", async () => {
|
||||
const d = await spawn1();
|
||||
const boardDir = makeTmpDir("board-redir");
|
||||
try {
|
||||
const board = await publishBoard({
|
||||
port: d.port,
|
||||
html: makeBoardHtml(boardDir),
|
||||
});
|
||||
// Use redirect: 'manual' so we observe the 301 response itself
|
||||
const res = await fetch(`http://127.0.0.1:${d.port}/boards/${board.id}`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.get("Location")).toBe(`/boards/${board.id}/`);
|
||||
} finally {
|
||||
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regenerate + reload round-trip ──────────────────────────────
|
||||
|
||||
describe("daemon round-trip: publish → regenerate → reload → submit round 2", () => {
|
||||
test("Full regen cycle: feedback-pending.json then reload swaps HTML", async () => {
|
||||
const d = await spawn1();
|
||||
const boardDir = makeTmpDir("board-regen");
|
||||
try {
|
||||
const r1Path = makeBoardHtml(boardDir, "<p>round 1 variants</p>");
|
||||
const board = await publishBoard({ port: d.port, html: r1Path });
|
||||
|
||||
// Skill issues a regenerate via the board JS path
|
||||
const regen = await fetch(`${board.url}api/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
preferred: "A",
|
||||
ratings: { A: 4 },
|
||||
regenerated: true,
|
||||
regenerateAction: "more_like_A",
|
||||
}),
|
||||
});
|
||||
expect(regen.status).toBe(200);
|
||||
expect(((await regen.json()) as any).action).toBe("regenerate");
|
||||
|
||||
// Pending file exists, final feedback file does not
|
||||
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
|
||||
|
||||
// Progress reflects regenerating state
|
||||
const prog1 = await fetch(`${board.url}api/progress`);
|
||||
expect(((await prog1.json()) as any).status).toBe("regenerating");
|
||||
|
||||
// Agent generates round 2, writes a new HTML file, calls /api/reload
|
||||
const r2Path = path.join(boardDir, "round2.html");
|
||||
fs.writeFileSync(r2Path, "<!DOCTYPE html><html><body><p>round 2 variants</p></body></html>");
|
||||
const reload = await fetch(`${board.url}api/reload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: r2Path }),
|
||||
});
|
||||
expect(reload.status).toBe(200);
|
||||
|
||||
// Same URL now serves the round-2 content (no port change, no
|
||||
// new browser tab — the user's existing tab can reload in place)
|
||||
const r2Page = await fetch(board.url);
|
||||
expect(await r2Page.text()).toContain("round 2 variants");
|
||||
expect(((await (await fetch(`${board.url}api/progress`)).json()) as any).status).toBe(
|
||||
"serving",
|
||||
);
|
||||
|
||||
// User submits round 2
|
||||
const finalSubmit = await fetch(`${board.url}api/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
preferred: "B",
|
||||
ratings: { B: 5 },
|
||||
regenerated: false,
|
||||
}),
|
||||
});
|
||||
expect(finalSubmit.status).toBe(200);
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
|
||||
);
|
||||
expect(written.preferred).toBe("B");
|
||||
expect(written.boardId).toBe(board.id);
|
||||
} finally {
|
||||
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Two-board, one-daemon attach behavior ───────────────────────
|
||||
|
||||
describe("daemon round-trip: two concurrent publishes share one daemon", () => {
|
||||
test("Second publish attaches to the same daemon (no new spawn)", async () => {
|
||||
const d = await spawn1();
|
||||
const dirA = makeTmpDir("two-a");
|
||||
const dirB = makeTmpDir("two-b");
|
||||
try {
|
||||
const a = await publishBoard({ port: d.port, html: makeBoardHtml(dirA) });
|
||||
const b = await publishBoard({ port: d.port, html: makeBoardHtml(dirB) });
|
||||
|
||||
// Same daemon process — state file pid is stable
|
||||
const state = readStateFile(stateFile);
|
||||
expect(state!.pid).toBe(d.proc.pid);
|
||||
|
||||
// Two distinct board ids
|
||||
expect(a.id).not.toBe(b.id);
|
||||
|
||||
// Both URLs serve their own content
|
||||
const pageA = await fetch(a.url);
|
||||
const pageB = await fetch(b.url);
|
||||
expect(pageA.status).toBe(200);
|
||||
expect(pageB.status).toBe(200);
|
||||
|
||||
// Feedback isolation: submit to A only affects A's sourceDir
|
||||
await fetch(`${a.url}api/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ regenerated: false, preferred: "A" }),
|
||||
});
|
||||
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
|
||||
|
||||
// Index page lists both
|
||||
const idx = await fetch(`http://127.0.0.1:${d.port}/`);
|
||||
const idxHtml = await idx.text();
|
||||
expect(idxHtml).toContain(a.id);
|
||||
expect(idxHtml).toContain(b.id);
|
||||
} finally {
|
||||
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
|
||||
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* End-to-end feedback round-trip test.
|
||||
*
|
||||
* This is THE test that proves "changes on the website propagate to the agent."
|
||||
* Tests the full pipeline:
|
||||
*
|
||||
* Browser click → JS fetch() → HTTP POST → server writes file → agent polls file
|
||||
*
|
||||
* The Kitsune bug: agent backgrounded $D serve, couldn't read stdout, user
|
||||
* clicked Regenerate, board showed spinner, agent never saw the feedback.
|
||||
* Fix: server writes feedback-pending.json to disk. Agent polls for it.
|
||||
*
|
||||
* This test verifies every link in the chain.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { BrowserManager } from '../../browse/src/browser-manager';
|
||||
import { handleReadCommand } from '../../browse/src/read-commands';
|
||||
import { handleWriteCommand } from '../../browse/src/write-commands';
|
||||
import { generateCompareHtml } from '../src/compare';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
let bm: BrowserManager;
|
||||
let baseUrl: string;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let tmpDir: string;
|
||||
let boardHtmlPath: string;
|
||||
let serverState: string;
|
||||
|
||||
function createTestPng(filePath: string): void {
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
fs.writeFileSync(filePath, png);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = '/tmp/feedback-roundtrip-' + Date.now();
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
createTestPng(path.join(tmpDir, 'variant-A.png'));
|
||||
createTestPng(path.join(tmpDir, 'variant-B.png'));
|
||||
createTestPng(path.join(tmpDir, 'variant-C.png'));
|
||||
|
||||
const html = generateCompareHtml([
|
||||
path.join(tmpDir, 'variant-A.png'),
|
||||
path.join(tmpDir, 'variant-B.png'),
|
||||
path.join(tmpDir, 'variant-C.png'),
|
||||
]);
|
||||
boardHtmlPath = path.join(tmpDir, 'design-board.html');
|
||||
fs.writeFileSync(boardHtmlPath, html);
|
||||
|
||||
serverState = 'serving';
|
||||
|
||||
// This server mirrors the real serve.ts behavior:
|
||||
// - Serves board HTML at / (board JS uses relative URLs)
|
||||
// - Handles POST /api/feedback with file writes
|
||||
// - Handles GET /api/progress for regeneration polling
|
||||
// - Handles POST /api/reload for board swapping
|
||||
let currentHtml = html;
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
return new Response(currentHtml, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/progress') {
|
||||
return Response.json({ status: serverState });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/feedback') {
|
||||
return (async () => {
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch {
|
||||
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return Response.json({ error: 'Expected JSON object' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isSubmit = body.regenerated === false;
|
||||
const feedbackFile = isSubmit ? 'feedback.json' : 'feedback-pending.json';
|
||||
fs.writeFileSync(path.join(tmpDir, feedbackFile), JSON.stringify(body, null, 2));
|
||||
|
||||
if (isSubmit) {
|
||||
serverState = 'done';
|
||||
return Response.json({ received: true, action: 'submitted' });
|
||||
}
|
||||
serverState = 'regenerating';
|
||||
return Response.json({ received: true, action: 'regenerate' });
|
||||
})();
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/reload') {
|
||||
return (async () => {
|
||||
const body = await req.json();
|
||||
if (body.html && fs.existsSync(body.html)) {
|
||||
currentHtml = fs.readFileSync(body.html, 'utf-8');
|
||||
serverState = 'serving';
|
||||
return Response.json({ reloaded: true });
|
||||
}
|
||||
return Response.json({ error: 'Not found' }, { status: 400 });
|
||||
})();
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
|
||||
bm = new BrowserManager();
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { server.stop(); } catch {}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
});
|
||||
|
||||
// ─── The critical test: browser click → file on disk ─────────────
|
||||
|
||||
describe('Submit: browser click → feedback.json on disk', () => {
|
||||
test('clicking Submit writes feedback.json that the agent can poll for', async () => {
|
||||
// Clean up any prior files
|
||||
const feedbackPath = path.join(tmpDir, 'feedback.json');
|
||||
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
|
||||
serverState = 'serving';
|
||||
|
||||
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
// Verify the board detects HTTP mode (so postFeedback will actually fetch
|
||||
// instead of falling into the file:// DOM-only path)
|
||||
const httpDetected = await handleReadCommand('js', [
|
||||
"location.protocol === 'http:' || location.protocol === 'https:'"
|
||||
], bm);
|
||||
expect(httpDetected).toBe('true');
|
||||
|
||||
// User picks variant A, rates it 5 stars
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
|
||||
], bm);
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
|
||||
], bm);
|
||||
|
||||
// User adds overall feedback
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("overall-feedback").value = "Ship variant A"'
|
||||
], bm);
|
||||
|
||||
// User clicks Submit
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("submit-btn").click()'
|
||||
], bm);
|
||||
|
||||
// Wait a beat for the async POST to complete
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// THE CRITICAL ASSERTION: feedback.json exists on disk
|
||||
expect(fs.existsSync(feedbackPath)).toBe(true);
|
||||
|
||||
// Agent reads it (simulating the polling loop)
|
||||
const feedback = JSON.parse(fs.readFileSync(feedbackPath, 'utf-8'));
|
||||
expect(feedback.preferred).toBe('A');
|
||||
expect(feedback.ratings.A).toBe(5);
|
||||
expect(feedback.overall).toBe('Ship variant A');
|
||||
expect(feedback.regenerated).toBe(false);
|
||||
});
|
||||
|
||||
test('post-submit: inputs disabled, success message shown', async () => {
|
||||
// Wait for the async .then() callback to update the DOM
|
||||
// (the file write is instant but the fetch().then() in the browser is async)
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// After submit, the page should be read-only
|
||||
const submitBtnExists = await handleReadCommand('js', [
|
||||
'document.getElementById("submit-btn").style.display'
|
||||
], bm);
|
||||
// submit button is hidden after post-submit lifecycle
|
||||
expect(submitBtnExists).toBe('none');
|
||||
|
||||
const successVisible = await handleReadCommand('js', [
|
||||
'document.getElementById("success-msg").style.display'
|
||||
], bm);
|
||||
expect(successVisible).toBe('block');
|
||||
|
||||
// Success message should mention /design-shotgun
|
||||
const successText = await handleReadCommand('js', [
|
||||
'document.getElementById("success-msg").textContent'
|
||||
], bm);
|
||||
expect(successText).toContain('design-shotgun');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regenerate: browser click → feedback-pending.json on disk', () => {
|
||||
test('clicking Regenerate writes feedback-pending.json that the agent can poll for', async () => {
|
||||
// Clean up
|
||||
const pendingPath = path.join(tmpDir, 'feedback-pending.json');
|
||||
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||
serverState = 'serving';
|
||||
|
||||
// Fresh page
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
// User clicks "Totally different" chiclet
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||
], bm);
|
||||
|
||||
// User clicks Regenerate
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("regen-btn").click()'
|
||||
], bm);
|
||||
|
||||
// Wait for async POST
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// THE CRITICAL ASSERTION: feedback-pending.json exists on disk
|
||||
expect(fs.existsSync(pendingPath)).toBe(true);
|
||||
|
||||
// Agent reads it
|
||||
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
||||
expect(pending.regenerated).toBe(true);
|
||||
expect(pending.regenerateAction).toBe('different');
|
||||
|
||||
// Agent would delete it and act on it
|
||||
fs.unlinkSync(pendingPath);
|
||||
expect(fs.existsSync(pendingPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('"More like this" writes feedback-pending.json with variant reference', async () => {
|
||||
const pendingPath = path.join(tmpDir, 'feedback-pending.json');
|
||||
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||
serverState = 'serving';
|
||||
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
// Click "More like this" on variant B (index 1)
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelectorAll(".more-like-this")[1].click()'
|
||||
], bm);
|
||||
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
expect(fs.existsSync(pendingPath)).toBe(true);
|
||||
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
||||
expect(pending.regenerated).toBe(true);
|
||||
expect(pending.regenerateAction).toBe('more_like_B');
|
||||
|
||||
fs.unlinkSync(pendingPath);
|
||||
});
|
||||
|
||||
test('board shows spinner after regenerate (user stays on same tab)', async () => {
|
||||
serverState = 'serving';
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||
], bm);
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("regen-btn").click()'
|
||||
], bm);
|
||||
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// Board should show "Generating new designs..." text
|
||||
const bodyText = await handleReadCommand('js', [
|
||||
'document.body.textContent'
|
||||
], bm);
|
||||
expect(bodyText).toContain('Generating new designs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full regeneration round-trip: regen → reload → submit', () => {
|
||||
test('agent can reload board after regeneration, user submits on round 2', async () => {
|
||||
// Clean start
|
||||
const pendingPath = path.join(tmpDir, 'feedback-pending.json');
|
||||
const feedbackPath = path.join(tmpDir, 'feedback.json');
|
||||
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
|
||||
serverState = 'serving';
|
||||
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
// Step 1: User clicks Regenerate
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
|
||||
], bm);
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("regen-btn").click()'
|
||||
], bm);
|
||||
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// Agent polls and finds feedback-pending.json
|
||||
expect(fs.existsSync(pendingPath)).toBe(true);
|
||||
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
||||
expect(pending.regenerateAction).toBe('match');
|
||||
fs.unlinkSync(pendingPath);
|
||||
|
||||
// Step 2: Agent generates new variants and creates a new board
|
||||
const newBoardPath = path.join(tmpDir, 'design-board-v2.html');
|
||||
const newHtml = generateCompareHtml([
|
||||
path.join(tmpDir, 'variant-A.png'),
|
||||
path.join(tmpDir, 'variant-B.png'),
|
||||
path.join(tmpDir, 'variant-C.png'),
|
||||
]);
|
||||
fs.writeFileSync(newBoardPath, newHtml);
|
||||
|
||||
// Step 3: Agent POSTs /api/reload to swap the board
|
||||
const reloadRes = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: newBoardPath }),
|
||||
});
|
||||
const reloadData = await reloadRes.json();
|
||||
expect(reloadData.reloaded).toBe(true);
|
||||
expect(serverState).toBe('serving');
|
||||
|
||||
// Step 4: Board auto-refreshes (simulated by navigating again)
|
||||
await handleWriteCommand('goto', [baseUrl], bm);
|
||||
|
||||
// Verify the board is fresh (no prior picks)
|
||||
const status = await handleReadCommand('js', [
|
||||
'document.getElementById("status").textContent'
|
||||
], bm);
|
||||
expect(status).toBe('');
|
||||
|
||||
// Step 5: User picks variant C on round 2 and submits
|
||||
await handleReadCommand('js', [
|
||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
|
||||
], bm);
|
||||
await handleReadCommand('js', [
|
||||
'document.getElementById("submit-btn").click()'
|
||||
], bm);
|
||||
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
|
||||
// Agent polls and finds feedback.json (submit = final)
|
||||
expect(fs.existsSync(feedbackPath)).toBe(true);
|
||||
const final = JSON.parse(fs.readFileSync(feedbackPath, 'utf-8'));
|
||||
expect(final.preferred).toBe('C');
|
||||
expect(final.regenerated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for the $D gallery command — design history timeline generation.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { generateGalleryHtml } from '../src/gallery';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function createTestPng(filePath: string): void {
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
fs.writeFileSync(filePath, png);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = '/tmp/gallery-test-' + Date.now();
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Gallery generation', () => {
|
||||
test('empty directory returns "No history" page', () => {
|
||||
const emptyDir = path.join(tmpDir, 'empty');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const html = generateGalleryHtml(emptyDir);
|
||||
expect(html).toContain('No design history yet');
|
||||
expect(html).toContain('/design-shotgun');
|
||||
});
|
||||
|
||||
test('nonexistent directory returns "No history" page', () => {
|
||||
const html = generateGalleryHtml('/nonexistent/path');
|
||||
expect(html).toContain('No design history yet');
|
||||
});
|
||||
|
||||
test('single session with approved variant', () => {
|
||||
const sessionDir = path.join(tmpDir, 'designs', 'homepage-20260327');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
createTestPng(path.join(sessionDir, 'variant-A.png'));
|
||||
createTestPng(path.join(sessionDir, 'variant-B.png'));
|
||||
createTestPng(path.join(sessionDir, 'variant-C.png'));
|
||||
|
||||
fs.writeFileSync(path.join(sessionDir, 'approved.json'), JSON.stringify({
|
||||
approved_variant: 'B',
|
||||
feedback: 'Great spacing and colors',
|
||||
date: '2026-03-27T12:00:00Z',
|
||||
screen: 'homepage',
|
||||
}));
|
||||
|
||||
const html = generateGalleryHtml(path.join(tmpDir, 'designs'));
|
||||
expect(html).toContain('Design History');
|
||||
expect(html).toContain('1 exploration');
|
||||
expect(html).toContain('homepage');
|
||||
expect(html).toContain('2026-03-27');
|
||||
expect(html).toContain('approved');
|
||||
expect(html).toContain('Great spacing and colors');
|
||||
// Should have 3 variant images (base64)
|
||||
expect(html).toContain('data:image/png;base64,');
|
||||
});
|
||||
|
||||
test('multiple sessions sorted by date (newest first)', () => {
|
||||
const dir = path.join(tmpDir, 'multi');
|
||||
const session1 = path.join(dir, 'settings-20260301');
|
||||
const session2 = path.join(dir, 'dashboard-20260315');
|
||||
fs.mkdirSync(session1, { recursive: true });
|
||||
fs.mkdirSync(session2, { recursive: true });
|
||||
|
||||
createTestPng(path.join(session1, 'variant-A.png'));
|
||||
createTestPng(path.join(session2, 'variant-A.png'));
|
||||
|
||||
fs.writeFileSync(path.join(session1, 'approved.json'), JSON.stringify({
|
||||
approved_variant: 'A', date: '2026-03-01T12:00:00Z',
|
||||
}));
|
||||
fs.writeFileSync(path.join(session2, 'approved.json'), JSON.stringify({
|
||||
approved_variant: 'A', date: '2026-03-15T12:00:00Z',
|
||||
}));
|
||||
|
||||
const html = generateGalleryHtml(dir);
|
||||
expect(html).toContain('2 explorations');
|
||||
// Dashboard (Mar 15) should appear before settings (Mar 1)
|
||||
const dashIdx = html.indexOf('dashboard');
|
||||
const settingsIdx = html.indexOf('settings');
|
||||
expect(dashIdx).toBeLessThan(settingsIdx);
|
||||
});
|
||||
|
||||
test('corrupted approved.json is handled gracefully', () => {
|
||||
const dir = path.join(tmpDir, 'corrupt');
|
||||
const session = path.join(dir, 'broken-20260327');
|
||||
fs.mkdirSync(session, { recursive: true });
|
||||
|
||||
createTestPng(path.join(session, 'variant-A.png'));
|
||||
fs.writeFileSync(path.join(session, 'approved.json'), 'NOT VALID JSON {{{');
|
||||
|
||||
const html = generateGalleryHtml(dir);
|
||||
// Should still render the session, just without any variant marked as approved
|
||||
expect(html).toContain('Design History');
|
||||
expect(html).toContain('broken');
|
||||
// The class "approved" should not appear on any variant div (only in CSS definition)
|
||||
expect(html).not.toContain('class="gallery-variant approved"');
|
||||
});
|
||||
|
||||
test('session without approved.json still renders', () => {
|
||||
const dir = path.join(tmpDir, 'no-approved');
|
||||
const session = path.join(dir, 'draft-20260327');
|
||||
fs.mkdirSync(session, { recursive: true });
|
||||
|
||||
createTestPng(path.join(session, 'variant-A.png'));
|
||||
createTestPng(path.join(session, 'variant-B.png'));
|
||||
|
||||
const html = generateGalleryHtml(dir);
|
||||
expect(html).toContain('draft');
|
||||
// No variant should be marked as approved
|
||||
expect(html).not.toContain('class="gallery-variant approved"');
|
||||
});
|
||||
|
||||
test('HTML is self-contained (no external dependencies)', () => {
|
||||
const dir = path.join(tmpDir, 'self-contained');
|
||||
const session = path.join(dir, 'test-20260327');
|
||||
fs.mkdirSync(session, { recursive: true });
|
||||
createTestPng(path.join(session, 'variant-A.png'));
|
||||
|
||||
const html = generateGalleryHtml(dir);
|
||||
// No external CSS/JS/image links
|
||||
expect(html).not.toContain('href="http');
|
||||
expect(html).not.toContain('src="http');
|
||||
expect(html).not.toContain('<link');
|
||||
// All images are base64
|
||||
expect(html).toContain('data:image/png;base64,');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Tests for the $D serve command — HTTP server for comparison board feedback.
|
||||
*
|
||||
* Tests the stateful server lifecycle:
|
||||
* - SERVING → POST submit → DONE (exit 0)
|
||||
* - SERVING → POST regenerate → REGENERATING → POST reload → SERVING
|
||||
* - Timeout → exit 1
|
||||
* - Error handling (missing HTML, malformed JSON, missing reload path)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { generateCompareHtml } from '../src/compare';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
let tmpDir: string;
|
||||
let boardHtml: string;
|
||||
|
||||
// Create a minimal 1x1 pixel PNG for test variants
|
||||
function createTestPng(filePath: string): void {
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
fs.writeFileSync(filePath, png);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = '/tmp/serve-test-' + Date.now();
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Create test PNGs and generate comparison board
|
||||
createTestPng(path.join(tmpDir, 'variant-A.png'));
|
||||
createTestPng(path.join(tmpDir, 'variant-B.png'));
|
||||
createTestPng(path.join(tmpDir, 'variant-C.png'));
|
||||
|
||||
const html = generateCompareHtml([
|
||||
path.join(tmpDir, 'variant-A.png'),
|
||||
path.join(tmpDir, 'variant-B.png'),
|
||||
path.join(tmpDir, 'variant-C.png'),
|
||||
]);
|
||||
boardHtml = path.join(tmpDir, 'design-board.html');
|
||||
fs.writeFileSync(boardHtml, html);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Serve as HTTP module (not subprocess) ────────────────────────
|
||||
|
||||
describe('Serve HTTP endpoints', () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
let htmlContent: string;
|
||||
let state: string;
|
||||
|
||||
beforeAll(() => {
|
||||
htmlContent = fs.readFileSync(boardHtml, 'utf-8');
|
||||
state = 'serving';
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
// Board JS uses relative URLs (./api/feedback, ./api/progress)
|
||||
// and a location.protocol feature-detect; no injection needed.
|
||||
return new Response(htmlContent, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/progress') {
|
||||
return Response.json({ status: state });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/feedback') {
|
||||
return (async () => {
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||
if (typeof body !== 'object' || body === null) return Response.json({ error: 'Expected JSON object' }, { status: 400 });
|
||||
const isSubmit = body.regenerated === false;
|
||||
const feedbackFile = isSubmit ? 'feedback.json' : 'feedback-pending.json';
|
||||
fs.writeFileSync(path.join(tmpDir, feedbackFile), JSON.stringify(body, null, 2));
|
||||
if (isSubmit) {
|
||||
state = 'done';
|
||||
return Response.json({ received: true, action: 'submitted' });
|
||||
}
|
||||
state = 'regenerating';
|
||||
return Response.json({ received: true, action: 'regenerate' });
|
||||
})();
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/reload') {
|
||||
return (async () => {
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||
if (!body.html || !fs.existsSync(body.html)) {
|
||||
return Response.json({ error: `HTML file not found: ${body.html}` }, { status: 400 });
|
||||
}
|
||||
htmlContent = fs.readFileSync(body.html, 'utf-8');
|
||||
state = 'serving';
|
||||
return Response.json({ reloaded: true });
|
||||
})();
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
test('GET / serves HTML with relative-path board JS (no injection)', async () => {
|
||||
const res = await fetch(baseUrl);
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
// No more per-origin URL injection; board JS uses relative paths.
|
||||
expect(html).not.toContain('__GSTACK_SERVER_URL');
|
||||
expect(html).not.toContain(baseUrl);
|
||||
// Board JS calls relative endpoints so the same HTML works at / and at
|
||||
// /boards/<id>/ (daemon mode).
|
||||
expect(html).toContain("fetch('./api/feedback'");
|
||||
expect(html).toContain("fetch('./api/progress')");
|
||||
expect(html).toContain('Design Exploration');
|
||||
});
|
||||
|
||||
test('GET /api/progress returns current state', async () => {
|
||||
state = 'serving';
|
||||
const res = await fetch(`${baseUrl}/api/progress`);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('serving');
|
||||
});
|
||||
|
||||
test('POST /api/feedback with submit sets state to done', async () => {
|
||||
state = 'serving';
|
||||
const feedback = {
|
||||
preferred: 'A',
|
||||
ratings: { A: 4, B: 3, C: 2 },
|
||||
comments: { A: 'Good spacing' },
|
||||
overall: 'Go with A',
|
||||
regenerated: false,
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedback),
|
||||
});
|
||||
const data = await res.json();
|
||||
expect(data.received).toBe(true);
|
||||
expect(data.action).toBe('submitted');
|
||||
expect(state).toBe('done');
|
||||
|
||||
// Verify feedback.json was written
|
||||
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'feedback.json'), 'utf-8'));
|
||||
expect(written.preferred).toBe('A');
|
||||
expect(written.ratings.A).toBe(4);
|
||||
});
|
||||
|
||||
test('POST /api/feedback with regenerate sets state and writes feedback-pending.json', async () => {
|
||||
state = 'serving';
|
||||
// Clean up any prior pending file
|
||||
const pendingPath = path.join(tmpDir, 'feedback-pending.json');
|
||||
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||
|
||||
const feedback = {
|
||||
preferred: 'B',
|
||||
ratings: { A: 3, B: 5, C: 2 },
|
||||
comments: {},
|
||||
overall: null,
|
||||
regenerated: true,
|
||||
regenerateAction: 'different',
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedback),
|
||||
});
|
||||
const data = await res.json();
|
||||
expect(data.received).toBe(true);
|
||||
expect(data.action).toBe('regenerate');
|
||||
expect(state).toBe('regenerating');
|
||||
|
||||
// Progress should reflect regenerating state
|
||||
const progress = await fetch(`${baseUrl}/api/progress`);
|
||||
const pd = await progress.json();
|
||||
expect(pd.status).toBe('regenerating');
|
||||
|
||||
// Agent can poll for feedback-pending.json
|
||||
expect(fs.existsSync(pendingPath)).toBe(true);
|
||||
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
||||
expect(pending.regenerated).toBe(true);
|
||||
expect(pending.regenerateAction).toBe('different');
|
||||
});
|
||||
|
||||
test('POST /api/feedback with remix contains remixSpec', async () => {
|
||||
state = 'serving';
|
||||
const feedback = {
|
||||
preferred: null,
|
||||
ratings: { A: 4, B: 3, C: 3 },
|
||||
comments: {},
|
||||
overall: null,
|
||||
regenerated: true,
|
||||
regenerateAction: 'remix',
|
||||
remixSpec: { layout: 'A', colors: 'B', typography: 'C' },
|
||||
};
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feedback),
|
||||
});
|
||||
const data = await res.json();
|
||||
expect(data.received).toBe(true);
|
||||
expect(state).toBe('regenerating');
|
||||
});
|
||||
|
||||
test('POST /api/feedback with malformed JSON returns 400', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: 'not json',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /api/feedback with non-object returns 400', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '"just a string"',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /api/reload swaps HTML and resets state to serving', async () => {
|
||||
state = 'regenerating';
|
||||
|
||||
// Create a new board HTML
|
||||
const newBoard = path.join(tmpDir, 'new-board.html');
|
||||
fs.writeFileSync(newBoard, '<html><body>New board content</body></html>');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: newBoard }),
|
||||
});
|
||||
const data = await res.json();
|
||||
expect(data.reloaded).toBe(true);
|
||||
expect(state).toBe('serving');
|
||||
|
||||
// Verify the new HTML is served
|
||||
const pageRes = await fetch(baseUrl);
|
||||
const pageHtml = await pageRes.text();
|
||||
expect(pageHtml).toContain('New board content');
|
||||
});
|
||||
|
||||
test('POST /api/reload with missing file returns 400', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: '/nonexistent/file.html' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /unknown returns 404', async () => {
|
||||
const res = await fetch(`${baseUrl}/random-path`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Path traversal protection in /api/reload ─────────────────────
|
||||
|
||||
describe('Serve /api/reload — path traversal protection', () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
let htmlContent: string;
|
||||
let allowedDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Production-equivalent allowedDir anchored to tmpDir
|
||||
allowedDir = fs.realpathSync(tmpDir);
|
||||
htmlContent = fs.readFileSync(boardHtml, 'utf-8');
|
||||
|
||||
// This server mirrors the production serve() with the path validation fix
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
return new Response(htmlContent, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/reload') {
|
||||
return (async () => {
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||
if (!body.html || !fs.existsSync(body.html)) {
|
||||
return Response.json({ error: `HTML file not found: ${body.html}` }, { status: 400 });
|
||||
}
|
||||
// Production path validation — same as design/src/serve.ts
|
||||
const resolvedReload = fs.realpathSync(path.resolve(body.html));
|
||||
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
|
||||
return Response.json({ error: `Path must be within: ${allowedDir}` }, { status: 403 });
|
||||
}
|
||||
if (!fs.statSync(resolvedReload).isFile()) {
|
||||
return Response.json({ error: `Path must be a file, not a directory: ${body.html}` }, { status: 400 });
|
||||
}
|
||||
htmlContent = fs.readFileSync(resolvedReload, 'utf-8');
|
||||
return Response.json({ reloaded: true });
|
||||
})();
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop();
|
||||
});
|
||||
|
||||
test('blocks reload with path outside allowed directory', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: '/etc/passwd' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('Path must be within');
|
||||
});
|
||||
|
||||
test('blocks reload with symlink pointing outside allowed directory', async () => {
|
||||
const linkPath = path.join(tmpDir, 'evil-link.html');
|
||||
try {
|
||||
fs.symlinkSync('/etc/passwd', linkPath);
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: linkPath }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
} finally {
|
||||
try { fs.unlinkSync(linkPath); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test('allows reload with file inside allowed directory', async () => {
|
||||
const goodPath = path.join(tmpDir, 'safe-board.html');
|
||||
fs.writeFileSync(goodPath, '<html><body>Safe reload</body></html>');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: goodPath }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.reloaded).toBe(true);
|
||||
|
||||
// Verify the new content is served
|
||||
const page = await fetch(baseUrl);
|
||||
expect(await page.text()).toContain('Safe reload');
|
||||
});
|
||||
|
||||
// Regression for the directory-instead-of-file guard (Codex finding).
|
||||
// Before: resolvedReload === allowedDir passed the guard and then
|
||||
// readFileSync threw EISDIR with no helpful message.
|
||||
test('blocks reload when path resolves to the allowed directory itself', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: tmpDir }),
|
||||
});
|
||||
// tmpDir does not satisfy startsWith(allowedDir + sep), so the within-dir
|
||||
// check rejects with 403 — but importantly, no EISDIR crash.
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('blocks reload when path is a subdirectory (not a file)', async () => {
|
||||
const subdir = path.join(tmpDir, 'subdir-not-a-file');
|
||||
fs.mkdirSync(subdir, { recursive: true });
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: subdir }),
|
||||
});
|
||||
// Inside allowedDir but a directory — must fail before readFileSync,
|
||||
// with a clear "must be a file" error instead of EISDIR.
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('must be a file');
|
||||
} finally {
|
||||
try { fs.rmSync(subdir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Full lifecycle: regeneration round-trip ──────────────────────
|
||||
|
||||
describe('Full regeneration lifecycle', () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let baseUrl: string;
|
||||
let htmlContent: string;
|
||||
let state: string;
|
||||
|
||||
beforeAll(() => {
|
||||
htmlContent = fs.readFileSync(boardHtml, 'utf-8');
|
||||
state = 'serving';
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
return new Response(htmlContent, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/api/progress') {
|
||||
return Response.json({ status: state });
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/api/feedback') {
|
||||
return (async () => {
|
||||
const body = await req.json();
|
||||
if (body.regenerated) { state = 'regenerating'; return Response.json({ received: true, action: 'regenerate' }); }
|
||||
state = 'done'; return Response.json({ received: true, action: 'submitted' });
|
||||
})();
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/api/reload') {
|
||||
return (async () => {
|
||||
const body = await req.json();
|
||||
if (body.html && fs.existsSync(body.html)) {
|
||||
htmlContent = fs.readFileSync(body.html, 'utf-8');
|
||||
state = 'serving';
|
||||
return Response.json({ reloaded: true });
|
||||
}
|
||||
return Response.json({ error: 'Not found' }, { status: 400 });
|
||||
})();
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => { server.stop(); });
|
||||
|
||||
test('regenerate → reload → submit round-trip', async () => {
|
||||
// Step 1: User clicks regenerate
|
||||
expect(state).toBe('serving');
|
||||
const regen = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ regenerated: true, regenerateAction: 'different', preferred: null, ratings: {}, comments: {} }),
|
||||
});
|
||||
expect((await regen.json()).action).toBe('regenerate');
|
||||
expect(state).toBe('regenerating');
|
||||
|
||||
// Step 2: Progress shows regenerating
|
||||
const prog1 = await (await fetch(`${baseUrl}/api/progress`)).json();
|
||||
expect(prog1.status).toBe('regenerating');
|
||||
|
||||
// Step 3: Agent generates new variants and reloads
|
||||
const newBoard = path.join(tmpDir, 'round2-board.html');
|
||||
fs.writeFileSync(newBoard, '<html><body>Round 2 variants</body></html>');
|
||||
const reload = await fetch(`${baseUrl}/api/reload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: newBoard }),
|
||||
});
|
||||
expect((await reload.json()).reloaded).toBe(true);
|
||||
expect(state).toBe('serving');
|
||||
|
||||
// Step 4: Progress shows serving (board would auto-refresh)
|
||||
const prog2 = await (await fetch(`${baseUrl}/api/progress`)).json();
|
||||
expect(prog2.status).toBe('serving');
|
||||
|
||||
// Step 5: User submits on round 2
|
||||
const submit = await fetch(`${baseUrl}/api/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ regenerated: false, preferred: 'B', ratings: { A: 3, B: 5 }, comments: {}, overall: 'B is great' }),
|
||||
});
|
||||
expect((await submit.json()).action).toBe('submitted');
|
||||
expect(state).toBe('done');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { generateVariant } from "../src/variants";
|
||||
|
||||
// 1x1 transparent PNG, base64 — valid bytes that fs.writeFileSync can write.
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
||||
|
||||
function successResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
output: [{ type: "image_generation_call", result: TINY_PNG_BASE64 }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
function rateLimited(retryAfter?: string): Response {
|
||||
const headers: Record<string, string> = {};
|
||||
if (retryAfter !== undefined) headers["Retry-After"] = retryAfter;
|
||||
return new Response("rate limited", { status: 429, headers });
|
||||
}
|
||||
|
||||
interface CallRecord {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
function makeStubFetch(
|
||||
responses: Response[],
|
||||
calls: CallRecord[],
|
||||
): typeof globalThis.fetch {
|
||||
let idx = 0;
|
||||
return (async (_input: any, _init?: any) => {
|
||||
calls.push({ ts: Date.now() });
|
||||
const response = responses[idx];
|
||||
if (!response) throw new Error(`stub fetch: no response for call ${idx + 1}`);
|
||||
idx++;
|
||||
return response;
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
describe("generateVariant Retry-After handling", () => {
|
||||
let tmpDir: string;
|
||||
let outputPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "variants-retry-after-"));
|
||||
outputPath = path.join(tmpDir, "variant.png");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("delta-seconds: honors Retry-After: 1 with no extra leading exponential", async () => {
|
||||
const calls: CallRecord[] = [];
|
||||
const fetchFn = makeStubFetch([rateLimited("1"), successResponse()], calls);
|
||||
|
||||
const result = await generateVariant(
|
||||
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls.length).toBe(2);
|
||||
const gap = calls[1].ts - calls[0].ts;
|
||||
// Honored ~1s; should NOT add the 2s leading exponential on top
|
||||
expect(gap).toBeGreaterThanOrEqual(900);
|
||||
expect(gap).toBeLessThan(1700);
|
||||
});
|
||||
|
||||
test("HTTP-date: honors a future date with no extra leading exponential", async () => {
|
||||
const calls: CallRecord[] = [];
|
||||
const future = new Date(Date.now() + 3000).toUTCString();
|
||||
const fetchFn = makeStubFetch([rateLimited(future), successResponse()], calls);
|
||||
|
||||
const result = await generateVariant(
|
||||
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls.length).toBe(2);
|
||||
const gap = calls[1].ts - calls[0].ts;
|
||||
expect(gap).toBeGreaterThanOrEqual(2500);
|
||||
expect(gap).toBeLessThan(4500);
|
||||
});
|
||||
|
||||
test("invalid Retry-After (alphanumeric): falls through to exponential", async () => {
|
||||
const calls: CallRecord[] = [];
|
||||
const fetchFn = makeStubFetch([rateLimited("2abc"), successResponse()], calls);
|
||||
|
||||
const result = await generateVariant(
|
||||
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls.length).toBe(2);
|
||||
const gap = calls[1].ts - calls[0].ts;
|
||||
// Falls through to existing 2s exponential leading delay
|
||||
expect(gap).toBeGreaterThanOrEqual(1800);
|
||||
expect(gap).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test("no Retry-After header: falls through to exponential", async () => {
|
||||
const calls: CallRecord[] = [];
|
||||
const fetchFn = makeStubFetch([rateLimited(), successResponse()], calls);
|
||||
|
||||
const result = await generateVariant(
|
||||
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls.length).toBe(2);
|
||||
const gap = calls[1].ts - calls[0].ts;
|
||||
expect(gap).toBeGreaterThanOrEqual(1800);
|
||||
expect(gap).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test("Retry-After: 0 retries immediately, skips leading exponential", async () => {
|
||||
const calls: CallRecord[] = [];
|
||||
const fetchFn = makeStubFetch([rateLimited("0"), successResponse()], calls);
|
||||
|
||||
const result = await generateVariant(
|
||||
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls.length).toBe(2);
|
||||
const gap = calls[1].ts - calls[0].ts;
|
||||
expect(gap).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user