mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-30 14:59:41 +02:00
cf50443b63
* refactor(design): board JS uses relative paths; drop __GSTACK_SERVER_URL injection
Board JS in design/src/compare.ts now calls ./api/feedback and ./api/progress
(relative to location.pathname) and feature-detects server mode via
location.protocol instead of the injected window.__GSTACK_SERVER_URL global.
The injection in design/src/serve.ts is removed (dead code now that nothing
reads it). Tests updated to match the new contract: serve.test.ts asserts
the relative-path JS is present and the global is gone; feedback-roundtrip
asserts location.protocol detects HTTP mode.
Why: prep for the multi-board daemon (design/src/daemon.ts upcoming) where
the same generated HTML is served at /boards/<id>/ instead of /. Relative
paths resolve against location.pathname in both cases, so one HTML, two
hosts. The injection was the only thing tying board JS to a specific
serving path; removing it unblocks the daemon work without forking the
generator.
file:// fallback preserved via the location.protocol feature-detect — board
opened directly as a file still falls through to the DOM-only success path.
The 6 feedback-roundtrip browser tests continue to fail with
session.clearLoadedHtml undefined; that failure pre-exists this branch
(verified against HEAD with these edits stashed) and lives in
browse/src/write-commands.ts, not in the design code path. Tracking
separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(design): reload guard rejects directory paths
design/src/serve.ts:200-212 used to accept a path that resolved to the
allowedDir itself (the OR branch `|| resolvedReload === allowedDir`),
which then crashed readFileSync with EISDIR. Now:
1. startsWith(allowedDir + path.sep) must pass — rejects the dir itself
and anything outside (403).
2. statSync(resolvedReload).isFile() must pass — rejects subdirectories
inside allowedDir with a clear "Path must be a file" 400.
The test stub in serve.test.ts mirrors prod; both updated, plus two new
test cases for the previously-broken paths. Codex caught this in the
plan-review pass; it's a latent bug in shipping code, not a regression
from the daemon work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): introduce design daemon — multi-board persistent server
Adds design/src/daemon.ts: a Bun.serve daemon that hosts many boards
under /boards/<id>/ instead of one server per `$D compare --serve` call.
Spawned by daemon-client (next commit); for now wired only via tests.
Endpoint table:
GET /health liveness + version + counts (unauth)
GET / index of recent boards
POST /api/boards publish; daemon derives sourceDir
from realpath(html). body sourceDir
IGNORED (Codex trust-boundary fix).
POST /shutdown graceful; refuses if active boards
exist (Codex data-loss fix)
GET /boards/<id> 301 → /boards/<id>/ (trailing slash
is load-bearing — relative URLs in
board JS resolve against pathname)
GET /boards/<id>/ render board HTML
GET /boards/<id>/api/progress state machine status (no idle reset)
POST /boards/<id>/api/feedback submit/regen; writes feedback.json
or feedback-pending.json with
boardId + publishedAt augmented in
POST /boards/<id>/api/reload swap HTML; per-board allowedDir
guard rejects traversal, directories,
out-of-allowed-dir symlinks
Lifecycle:
- 24h idle timeout (DESIGN_DAEMON_IDLE_MS for tests).
- Idle with active boards extends 1h up to 4x, then force-shuts (Codex).
- LRU cap 50 boards; evicts done before non-done; 503 when 50 non-done.
- Per-board async mutex serializes feedback POST vs reload POST.
- SIGTERM/SIGINT/uncaughtException → graceful shutdown, state file unlink.
- Stdout: DAEMON_STARTED port=<N> (the line the client parses).
Shared utilities live in design/src/daemon-state.ts: atomic state-file
write/read (mode 0o600), fs.openSync('wx') lock, isProcessAlive, cmdline
identity verification (/proc on Linux, ps on macOS), CMDLINE_MARKER
constant. Modeled on browse/src/cli.ts lock + spawn patterns.
design/test/daemon.test.ts: 30 tests, all green. Covers every endpoint,
both error paths and happy paths, cross-board feedback isolation, the
trailing-slash redirect, the directory-not-file reload rejection, LRU
preferring done over non-done, /shutdown refusal with active boards,
all path-traversal guards. Uses the exported fetchHandler in-process
(no spawn) so the suite runs in ~70ms.
design/test/daemon-tests-fixtures.ts: shared helpers — req() builder,
tmp-dir helpers, daemon reset, and a spawnDaemonForTest() helper used
by the next commit's discovery tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): daemon-client with lock + identity-verified spawn
design/src/daemon-client.ts implements the CLI side of the daemon lifecycle:
ensureDaemon() (the spawn-or-attach decision), publishBoard(), and the
$D daemon stop|status helpers.
Modeled on browse/src/cli.ts:317-415 — same health-check-first attach,
same fs.openSync('wx') lock, same re-read-state-INSIDE-the-lock guard
against two CLIs both deciding "no daemon, spawn." Two design-specific
safety properties added beyond browse:
1. verifyIdentity before any SIGTERM/SIGKILL. Reads the running process's
cmdline (/proc/PID/cmdline on Linux, `ps -p PID -o command=` on macOS)
and only signals if it contains CMDLINE_MARKER ("gstack-design-daemon",
passed as argv at spawn time). Prevents a stale state file from
causing us to kill an unrelated process that inherited the PID.
2. Refuse-kill-with-active-boards on version mismatch. Browse silently
restarts; here in-memory board history would vanish, so the client
prints a user-actionable WARNING and exit 1 instead. Users explicitly
`$D daemon stop` to override.
Spawn uses Node child_process.spawn (NOT Bun.spawn().unref) because of
the macOS session-detach quirks browse already discovered. Stdio is
redirected to ~/.gstack/design-daemon-startup.log, which the client
tails into stderr if waitForHealthOrError times out — no more silent
"daemon failed for some unknowable reason."
daemon-state.ts gains DESIGN_DAEMON_STATE_FILE env override so tests
can point both client and spawned daemon at a per-test path without a
shared cwd.
design/test/daemon-discovery.test.ts: 17 tests, all green in ~8s. Covers:
spawn-fresh, attach-existing, stale-state-file (pid dead), PID-reuse
safety (uses the test runner's own PID as the bait — verifyIdentity
catches the cmdline mismatch, daemon not signaled), version-mismatch
with/without active boards (the active-boards case runs a subprocess
and asserts exit 1 + WARNING in stderr), publishBoard 200 + 409,
shutdownDaemon refuse/force/unresponsive paths, daemonStatus.
The daemon-discovery suite is split out of daemon.test.ts because each
real spawn costs ~200ms; the in-process daemon.test.ts (30 tests, 70ms)
covers the same handler logic without the spawn overhead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): wire daemon dispatch into CLI; add daemon stop/status
design/src/cli.ts now branches on --no-daemon for both `compare --serve`
and standalone `serve --html`. Default path: ensureDaemon → publishBoard
→ openBrowser → exit. The legacy single-process serve() is preserved
behind --no-daemon for tests, Windows, and explicit debugging.
Adds $D daemon status (prints daemon state JSON, or {running:false})
and $D daemon stop [--force] (refuses with active boards unless --force).
parseArgs gains a `positionals` field so daemon sub-commands work
naturally (`$D daemon stop` instead of `$D --action stop`).
Stderr lines printed by the publishToDaemon path:
DAEMON_STARTED port=N (or DAEMON_ATTACHED port=N)
BOARD_PUBLISHED: <url>
BOARD_URL: <url> (alias for grep-friendliness)
Stdout: JSON with id, url, sourceDir.
design/src/commands.ts: --no-daemon, --title added to compare + serve;
new daemon command entry with status|stop sub-commands.
End-to-end smoke (manual): spawning a board via $D serve, hitting the
returned URL, reading /health, calling daemon status (returns the
right JSON), and daemon stop refusing because of the active board —
all work as designed. Force-stop tears down cleanly and removes the
state file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(design): end-to-end daemon round-trip via HTTP fetch
design/test/feedback-roundtrip-daemon.test.ts walks the full publish →
submit / regenerate / reload cycle against a real spawned daemon, using
the same HTTP calls the board JS makes. Four tests, all green in ~650ms.
Covers what design-shotgun and friends actually depend on:
- Submit writes feedback.json into the board's sourceDir with the
augmented boardId + publishedAt fields.
- GET /boards/<id> (no slash) returns a 301 to /boards/<id>/ — the
load-bearing redirect that lets the board JS use relative paths.
- Regenerate writes feedback-pending.json, flips state to regenerating,
/api/progress reflects it; /api/reload swaps HTML in place; round-2
submit writes the final feedback.json with the round-2 selection.
- Two boards published into the same daemon get independent URLs on
the same port — feedback for board A doesn't contaminate board B's
sourceDir, both URLs serve their own content, the index lists both.
Uses HTTP fetch rather than a real browser because the existing browser
round-trip (feedback-roundtrip.test.ts) is broken on a pre-existing
browse harness regression (session.clearLoadedHtml undefined in
browse/src/write-commands.ts:149) that's unrelated to this branch.
The HTTP path proves the same daemon semantics; a browser variant can
be added once the browse harness is fixed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design): compiled binary self-execs as daemon; unified version lookup
Two small but production-critical fixes once the binary actually runs:
1. Compiled binary couldn't spawn the daemon. daemon-client previously
pointed at design/src/daemon.ts via import.meta.dir — fine in dev,
fatal in production (the source path doesn't exist on a user's
machine). Fix: design CLI now self-execs in --daemon-mode when
invoked with that flag, so the spawn is `process.execPath
--daemon-mode --marker gstack-design-daemon` for the compiled binary
and `bun run cli.ts --daemon-mode ...` in dev. Same one binary, two
modes, no separate daemon entrypoint to ship.
2. Client and daemon disagreed on VERSION in the compiled binary.
Both used a source-tree-relative path that resolves to "unknown"
at runtime, which silently shorted the version-mismatch refusal
path (client expected "unknown" + daemon reported "unknown" → match
→ no refusal even when DESIGN_DAEMON_VERSION was set on one side).
New readVersionString() consults DESIGN_DAEMON_VERSION env first,
then design/dist/.version (sidecar baked at build time by build.sh),
then VERSION at the source-tree root. Both client and daemon now go
through this one helper.
Manual smoke (compiled binary, all checks green):
- DAEMON_STARTED + BOARD_PUBLISHED with trailing slash
- GET /boards/<id> (no slash) → 301 Location /boards/<id>/
- Second `$D serve` invocation → DAEMON_ATTACHED, new board on same port
- feedback.json gets boardId + publishedAt fields
- DESIGN_DAEMON_VERSION=v2-different on second invocation with
active board → WARNING + "Refusing to auto-kill" + exit 1,
original daemon still alive
- `$D daemon stop --force` removes state file
All 67 design tests still green after the refactor (16 serve + 30
daemon + 17 discovery + 4 daemon round-trip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(design): skill resolvers learn the daemon's BOARD_URL output
The five skills that invoke $D compare --serve (design-shotgun,
design-consultation, plan-design-review, office-hours, design-review)
parsed `SERVE_STARTED: port=N` from stderr and then POSTed to
`/api/reload` at that port during regenerate cycles. The new daemon
hosts boards under `/boards/<id>/` so the reload endpoint moved to
`<BOARD_URL>api/reload` — without this update, the regenerate phase
of every skill invocation would silently fail against daemon mode.
Updated scripts/resolvers/design.ts to parse `BOARD_URL:` instead of
the port, and to POST reloads against the per-board URL. Regenerated
the four SKILL.md files via bun run gen:skill-docs.
Legacy `--no-daemon` invocations continue to emit `SERVE_STARTED:` and
serve at `/api/reload` — the resolver instructions note both.
Surfaced by the maintainability specialist during /ship review (the
"stale comment" finding was actually a behavior bug pointing at five
downstream consumers). Codex's plan-review pass flagged the migration
story as incomplete but I dismissed the concern — Codex was right.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(design): emit SERVE_STARTED back-compat alias; drop dead import
design/src/cli.ts publishToDaemon now emits `SERVE_STARTED: port=N html=<path>`
as a third stderr line alongside DAEMON_STARTED/DAEMON_ATTACHED + BOARD_URL.
Any out-of-tree script that grepped the legacy line still gets the port —
they'd still fail at the reload step (the endpoint moved to /boards/<id>/
api/reload) but they no longer fail at the port-detection step. Combined with
the resolver updates one commit back, this is belt-and-suspenders compat.
Fixed the stale docstring at cli.ts:316 that claimed back-compat without
actually emitting the alias. The maintainability specialist flagged it.
Dropped a dead `DaemonState` import from daemon-client.ts. Same review pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.45.0.0)
Design boards now live 24h, not 10 minutes. One daemon hosts every
board, one tab survives the whole day. See CHANGELOG.md for the full
release summary + metrics + itemized changes.
TODOS.md gains a "design daemon: follow-ups" section capturing the
P3 test gaps + maintainability nits the /ship review army flagged
but that aren't blocking for this release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(design): fill daemon test gaps surfaced by ship review army
Adds 10 net new tests (and removes 1 misleading smoke) for the gaps the
testing specialist flagged at /ship time. Filed as P3 TODOs at ship,
filling now per boil-the-lake.
design/test/daemon-discovery.test.ts (+6 tests, +1 import):
- "idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS"
Spawn-based, DESIGN_DAEMON_IDLE_MS=2000, CHECK_MS=200. Waits for the
daemon process to actually exit and asserts the state file is removed.
Previously only "callable without throwing" was tested.
- "bare GET polling does NOT prevent idle shutdown"
Hammers /api/progress every 200ms in a background loop with a done
board, asserts the daemon still idles out — proves the
meaningful-activity-only-on-POSTs guard (Codex finding) actually works.
- "idle with active (non-done) boards triggers extension instead of shutdown"
Sets DESIGN_DAEMON_EXTENSION_MS=1500 + MAX_EXTENSIONS=2, publishes a
non-done board, asserts the daemon survives past IDLE_MS (extends),
then verifies the MAX_EXTENSIONS hard ceiling force-shuts. Both the
extension counter and the hard ceiling were previously untested.
- "two parallel ensureDaemon() calls converge on one daemon"
Fires two ensureDaemon calls in Promise.all against an empty stateFile,
asserts: both ports match, exactly one spawned=true, exactly one daemon
alive, no orphaned lock file. The discovery-test file's own docstring
claimed this test existed; now it actually does.
- "acquireLock reclaims a lockfile owned by a dead PID"
Plants a lockfile with PID 999999998, calls acquireLock, asserts the
returned release fn is non-null and the lock now holds our PID.
- "acquireLock refuses to reclaim a lockfile owned by an alive PID"
Uses the test runner's own PID — alive but not the lock's intended
owner. Asserts acquireLock returns null and leaves the lockfile
untouched. The unrelated-process-PID-reuse safety guard.
design/test/daemon.test.ts (-2 misleading, +5 new = +3 net):
- Removed: "bare GET /api/progress does NOT reset meaningful activity"
(smoke pretending to be behavioral — body comment admitted it couldn't
verify). Replaced by the spawn-based version in daemon-discovery above.
- Removed: "idleCheckTick is callable without throwing when there's no idle"
(collapsed into a single smoke describe that's clearer about its scope).
- Added: "POST /api/boards rejects invalid JSON body"
- Added: "POST /api/boards rejects non-object body (e.g. JSON null)"
- Added: "POST /api/boards: array body falls through to missing-html 400"
(documents the typeof-array-is-object JS quirk; will surface if we
ever tighten the type check)
- Added: "POST /boards/<id>/api/reload rejects invalid JSON body"
- Added: "POST /boards/<id>/api/reload rejects body missing html field"
Per-file totals after: serve 16, daemon 34, discovery 23, round-trip 4 = 77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update CHANGELOG + TODOS for filled test gaps in v1.45.0.0
Bumps the design test count from 67 → 77 (and the new-test delta from
+51 → +61) to reflect commit 6b037c55, which filled the 5 P3 test gaps
the /ship review army had filed to TODOS.md.
Marks the "Tighten daemon test coverage" entry in TODOS.md as DONE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|