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
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:
359
design/test/feedback-roundtrip.test.ts
Normal file
359
design/test/feedback-roundtrip.test.ts
Normal 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
139
design/test/gallery.test.ts
Normal 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
461
design/test/serve.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
133
design/test/variants-retry-after.test.ts
Normal file
133
design/test/variants-retry-after.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user