Initial import from garrytan/gstack@026751e (main snapshot via local relay)
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled

Source: https://github.com/garrytan/gstack/commit/026751e
This commit is contained in:
Rocky
2026-05-19 21:18:17 +02:00
commit 834c6db075
797 changed files with 267839 additions and 0 deletions

View File

@@ -0,0 +1,359 @@
/**
* 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:
// - Injects __GSTACK_SERVER_URL into the HTML
// - 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')) {
const injected = currentHtml.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
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 (served with __GSTACK_SERVER_URL injected)
await handleWriteCommand('goto', [baseUrl], bm);
// Verify __GSTACK_SERVER_URL was injected
const hasServerUrl = await handleReadCommand('js', [
'!!window.__GSTACK_SERVER_URL'
], bm);
expect(hasServerUrl).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);
});
});

139
design/test/gallery.test.ts Normal file
View File

@@ -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,');
});
});

461
design/test/serve.test.ts Normal file
View File

@@ -0,0 +1,461 @@
/**
* 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 === '/') {
const injected = htmlContent.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
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 injected __GSTACK_SERVER_URL', async () => {
const res = await fetch(baseUrl);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('__GSTACK_SERVER_URL');
expect(html).toContain(baseUrl);
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) && resolvedReload !== allowedDir) {
return Response.json({ error: `Path must be within: ${allowedDir}` }, { status: 403 });
}
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');
});
});
// ─── 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');
});
});

View File

@@ -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);
});
});