Files
gstack/browse/test/compare-board.test.ts
T
Garry TanandClaude Fable 5 e0bfc8fff5 fix(test): remove all 8 delayed process.exit teardown bombs — the tier-1 gate can finally fail
bun test runs every file in ONE process, so a 500ms setTimeout(process.exit(0))
armed in afterAll fired mid-way through a LATER file and killed the entire
suite with exit 0 and no summary — only ~16 of 434 files ran, and every
downstream failure was invisible (observed live throughout this wave's
enumeration). Changes, all guarded by fault injection:

- Replace every delayed-exit teardown with a time-boxed close of the file's
  own browser (8 files across browse/ and design/); stub the daemon
  /shutdown timer instead of letting its unconditional process.exit tear
  the runner down.
- test/no-suicide-exit.test.ts: static tripwire — no *.test.ts may schedule
  a delayed process.exit again.
- test/exit-propagation.test.ts + fixtures: fault injection with REAL bun
  output proves the truncation shape (exit 0, no summary) and that
  scripts/test-free-shards.ts now detects it: a shard exiting 0 WITHOUT
  bun's final summary line is treated as FAILED (exit code alone is not
  evidence of completion).
- handoff: the three headed-mode integration tests are darwin-skipped with
  a pointer to the known macOS headed-launch breakage (#2242/#2554); they
  keep running on Linux CI. Un-skip in the browse-daemon wave.
- feedback-roundtrip: repair the handler call sites unmasked by the fix —
  handlers take (command, args, session, bm); passing the manager where a
  session belongs broke all six tests.
- user-slug-fallback: HOME isolation makes endpoint_hash deterministic.

Fixes #2421, #2435.

Contributed by @sneakygriff (PR #2172) with repairs from @time-attack
(PR #2230 feedback-roundtrip hunks); supersedes PR #2252 by @whd4 (same
defect, credited).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:20:51 -07:00

353 lines
12 KiB
TypeScript

/**
* Integration test for the design comparison board feedback loop.
*
* Tests the DOM polling pattern that plan-design-review, office-hours,
* and design-consultation use to read user feedback from the comparison board.
*
* Flow: generate board HTML → open in browser → verify DOM elements →
* simulate user interaction → verify structured JSON feedback.
*
* No LLM involved — this is a deterministic functional test.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { BrowserManager } from '../src/browser-manager';
import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleReadCommand(cmd, args, b.getActiveSession());
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
import { generateCompareHtml } from '../../design/src/compare';
import * as fs from 'fs';
import * as path from 'path';
let bm: BrowserManager;
let boardUrl: string;
let server: ReturnType<typeof Bun.serve>;
let tmpDir: string;
// Create a minimal 1x1 pixel PNG for test variants
function createTestPng(filePath: string): void {
// Minimal valid PNG: 1x1 red pixel
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
'base64'
);
fs.writeFileSync(filePath, png);
}
beforeAll(async () => {
// Create test PNG files
tmpDir = '/tmp/compare-board-test-' + 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'));
// Generate comparison board HTML using the real compare module
const html = generateCompareHtml([
path.join(tmpDir, 'variant-A.png'),
path.join(tmpDir, 'variant-B.png'),
path.join(tmpDir, 'variant-C.png'),
]);
// Serve the board via HTTP (browse blocks file:// URLs for security)
server = Bun.serve({
port: 0,
fetch() {
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
},
});
boardUrl = `http://localhost:${server.port}`;
// Launch browser and navigate to the board
bm = new BrowserManager();
await bm.launch();
await handleWriteCommand('goto', [boardUrl], bm);
});
afterAll(async () => {
try { server.stop(); } catch {}
fs.rmSync(tmpDir, { recursive: true, force: true });
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── DOM Structure ──────────────────────────────────────────────
describe('Comparison board DOM structure', () => {
test('has hidden status element', async () => {
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('');
});
test('has hidden feedback-result element', async () => {
const result = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
expect(result).toBe('');
});
test('has submit button', async () => {
const exists = await handleReadCommand('js', [
'!!document.getElementById("submit-btn")'
], bm);
expect(exists).toBe('true');
});
test('has regenerate button', async () => {
const exists = await handleReadCommand('js', [
'!!document.getElementById("regen-btn")'
], bm);
expect(exists).toBe('true');
});
test('has 3 variant cards', async () => {
const count = await handleReadCommand('js', [
'document.querySelectorAll(".variant").length'
], bm);
expect(count).toBe('3');
});
test('has pick radio buttons for each variant', async () => {
const count = await handleReadCommand('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]").length'
], bm);
expect(count).toBe('3');
});
test('has star ratings for each variant', async () => {
const count = await handleReadCommand('js', [
'document.querySelectorAll(".stars").length'
], bm);
expect(count).toBe('3');
});
});
// ─── Submit Flow ────────────────────────────────────────────────
describe('Submit feedback flow', () => {
test('submit without interaction returns empty preferred', async () => {
// Reset page state
await handleWriteCommand('goto', [boardUrl], bm);
// Click submit without picking anything
await handleReadCommand('js', [
'document.getElementById("submit-btn").click()'
], bm);
// Status should be "submitted"
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('submitted');
// Read feedback JSON
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.preferred).toBeNull();
expect(feedback.regenerated).toBe(false);
expect(feedback.ratings).toBeDefined();
});
test('submit with pick + rating + comment returns structured JSON', async () => {
// Fresh page
await handleWriteCommand('goto', [boardUrl], bm);
// Pick variant B
await handleReadCommand('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[1].click()'
], bm);
// Rate variant A: 4 stars (click the 4th star)
await handleReadCommand('js', [
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[3].click()'
], bm);
// Rate variant B: 5 stars
await handleReadCommand('js', [
'document.querySelectorAll(".stars")[1].querySelectorAll(".star")[4].click()'
], bm);
// Add comment on variant A
await handleReadCommand('js', [
'document.querySelectorAll(".feedback-input")[0].value = "Good spacing but wrong colors"'
], bm);
// Add overall feedback
await handleReadCommand('js', [
'document.getElementById("overall-feedback").value = "Go with B, make the CTA bigger"'
], bm);
// Submit
await handleReadCommand('js', [
'document.getElementById("submit-btn").click()'
], bm);
// Verify status
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('submitted');
// Read and verify structured feedback
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.preferred).toBe('B');
expect(feedback.ratings.A).toBe(4);
expect(feedback.ratings.B).toBe(5);
expect(feedback.comments.A).toBe('Good spacing but wrong colors');
expect(feedback.overall).toBe('Go with B, make the CTA bigger');
expect(feedback.regenerated).toBe(false);
});
test('submit button is disabled after submission', async () => {
const disabled = await handleReadCommand('js', [
'document.getElementById("submit-btn").disabled'
], bm);
expect(disabled).toBe('true');
});
test('success message is visible after submission', async () => {
const display = await handleReadCommand('js', [
'document.getElementById("success-msg").style.display'
], bm);
expect(display).toBe('block');
});
});
// ─── Regenerate Flow ────────────────────────────────────────────
describe('Regenerate flow', () => {
test('regenerate button sets status to "regenerate"', async () => {
// Fresh page
await handleWriteCommand('goto', [boardUrl], bm);
// Click "Totally different" chiclet then regenerate
await handleReadCommand('js', [
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
], bm);
await handleReadCommand('js', [
'document.getElementById("regen-btn").click()'
], bm);
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('regenerate');
// Verify regenerate action in feedback
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.regenerated).toBe(true);
expect(feedback.regenerateAction).toBe('different');
});
test('"More like this" sets regenerate with variant reference', async () => {
// Fresh page
await handleWriteCommand('goto', [boardUrl], bm);
// Click "More like this" on variant B
await handleReadCommand('js', [
'document.querySelectorAll(".more-like-this")[1].click()'
], bm);
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('regenerate');
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.regenerated).toBe(true);
expect(feedback.regenerateAction).toBe('more_like_B');
});
test('regenerate with custom text', async () => {
// Fresh page
await handleWriteCommand('goto', [boardUrl], bm);
// Type custom regeneration text
await handleReadCommand('js', [
'document.getElementById("regen-custom-input").value = "V3 layout with V1 colors"'
], bm);
// Click regenerate (no chiclet selected = custom)
await handleReadCommand('js', [
'document.getElementById("regen-btn").click()'
], bm);
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.regenerated).toBe(true);
expect(feedback.regenerateAction).toBe('V3 layout with V1 colors');
});
});
// ─── Agent Polling Pattern ──────────────────────────────────────
describe('Agent polling pattern (simulates what $B eval does)', () => {
test('status is empty before user action', async () => {
// Fresh page — simulates agent's first poll
await handleWriteCommand('goto', [boardUrl], bm);
const status = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(status).toBe('');
});
test('full polling cycle: empty → submitted → read JSON', async () => {
await handleWriteCommand('goto', [boardUrl], bm);
// Poll 1: empty (user hasn't acted)
const poll1 = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(poll1).toBe('');
// User acts: pick A, submit
await handleReadCommand('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
], bm);
await handleReadCommand('js', [
'document.getElementById("submit-btn").click()'
], bm);
// Poll 2: submitted
const poll2 = await handleReadCommand('js', [
'document.getElementById("status").textContent'
], bm);
expect(poll2).toBe('submitted');
// Read feedback (what the agent does after seeing "submitted")
const raw = await handleReadCommand('js', [
'document.getElementById("feedback-result").textContent'
], bm);
const feedback = JSON.parse(raw);
expect(feedback.preferred).toBe('A');
expect(typeof feedback.ratings).toBe('object');
expect(typeof feedback.comments).toBe('object');
});
});