mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Three review findings on #582, all real and all mine. Delegating `.pip` and `.media` to `document` separately meant they could not stop each other: stopPropagation ends bubbling, not siblings on the same target. Clicking the corner inspiration opened the inspiration and then the media handler replaced it with the comp, so the corner was unusable on exactly the cards this PR set out to fix. All three targets now resolve in one delegated listener in priority order, corner before chip before slot, and a chip that is not expand keeps its own click instead of falling through. Flip-back restored the face without clearing what the pending state had added, so a slot that reached stand-in came back carrying "comp pending" beside a fresh label, and one whose art had failed came back still marked unavailable. Restore now clears both, and a slot with no art to restore returns to the honest "artwork unavailable" treatment rather than being labeled inspiration. Converting in place means the same node is reused across flip cycles, and the old poll closure outlived its cycle: a probe from the first flip could settle the second one, stripping the new shimmer and stopping the live poll while the comp stayed hidden. Each run now carries a generation stamp that flip-back bumps, and both probe callbacks bail when it moves. The test covers the corner click against the landed comp, and I confirmed it fails when the priority ordering is removed. Written with AI assistance (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
852 lines
42 KiB
JavaScript
852 lines
42 KiB
JavaScript
/**
|
|
* Deterministic smoke tests for the new-work interactive flow.
|
|
*
|
|
* Covers the parts a user actually touches: the serve-question decision page
|
|
* (pick, re-roll + steer + re-deal, canon, tab close) driven through a real
|
|
* browser by the scripted user bot, plus the offline fake image generator.
|
|
* No LLM calls; a real Chromium via Playwright supplies full page fidelity
|
|
* (heartbeats, re-roll reload, tab close). Kept OUT of `bun run test` like
|
|
* live-e2e; run it with `bun run test:new-work-e2e`.
|
|
*
|
|
* The concept-seed direction roll (challengers, ASSIGNED INDEX, the no
|
|
* PRODUCT.md gate) is already covered by tests/concept-seed.test.mjs and is
|
|
* not repeated here.
|
|
*
|
|
* One-time setup: npx playwright install chromium
|
|
*/
|
|
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { runUserBot } from './new-work-e2e/user-bot.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const SERVE = path.join(ROOT, 'skill', 'scripts', 'serve-question.mjs');
|
|
const GENERATE = path.join(ROOT, 'skill', 'scripts', 'generate-image.mjs');
|
|
const CATALOG_DIR = path.join(ROOT, 'tests', 'fixtures', 'concept-catalog');
|
|
|
|
let playwright;
|
|
let browser;
|
|
|
|
before(async () => {
|
|
try {
|
|
playwright = await import('playwright');
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Playwright is required for new-work-e2e tests (${err.message}). Run: npx playwright install chromium`,
|
|
);
|
|
}
|
|
try {
|
|
browser = await playwright.chromium.launch({ headless: true });
|
|
} catch (err) {
|
|
throw new Error(`Failed to launch Chromium (${err.message}). Run: npx playwright install chromium`);
|
|
}
|
|
});
|
|
|
|
after(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Workspace + serve-question helpers
|
|
// --------------------------------------------------------------------------
|
|
function makeWorkspace() {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'new-work-e2e-'));
|
|
writeFileSync(
|
|
path.join(dir, 'PRODUCT.md'),
|
|
'# Product\n\n## Register\n\nbrand\n\n## Platform\n\nweb\n',
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
// serve-question writes its state under cwd; run everything from the workspace.
|
|
function run(args, cwd) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SERVE, ...args], {
|
|
cwd,
|
|
env: { ...process.env, IMPECCABLE_QUESTION_FORCE: '1', IMPECCABLE_CATALOG_DIR: CATALOG_DIR },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let out = '';
|
|
let err = '';
|
|
child.stdout.on('data', (c) => { out += c; });
|
|
child.stderr.on('data', (c) => { err += c; });
|
|
child.on('exit', (code) => resolve({ code, out, err }));
|
|
});
|
|
}
|
|
|
|
async function startDaemon(cwd, payload, key) {
|
|
const payloadPath = path.join(cwd, `${key}.payload.json`);
|
|
writeFileSync(payloadPath, JSON.stringify(payload));
|
|
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', key], cwd);
|
|
assert.equal(started.code, 0, `--start failed: ${started.out} ${started.err}`);
|
|
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
|
assert.ok(url, `no URL from --start: ${started.out}`);
|
|
return { url, payloadPath };
|
|
}
|
|
|
|
// Poll --wait until it settles on a terminal exit code (0 answered, 2 gone,
|
|
// 4 page closed); loop while it reports WAITING (3).
|
|
async function waitLoop(cwd, key, { poll = 30, max = 20 } = {}) {
|
|
for (let i = 0; i < max; i++) {
|
|
const res = await run(['--wait', '--key', key, '--poll', String(poll)], cwd);
|
|
if (res.code !== 3) return res;
|
|
}
|
|
throw new Error('waitLoop exceeded max iterations');
|
|
}
|
|
|
|
async function stopDaemon(cwd, key) {
|
|
await run(['--stop', '--key', key], cwd).catch(() => {});
|
|
}
|
|
|
|
function makeFakeImage(cwd, prompt, outName) {
|
|
const out = path.join(cwd, outName);
|
|
const res = spawnSyncGen(prompt, out);
|
|
assert.equal(res.status, 0, `generate-image fake failed: ${res.stderr}`);
|
|
return out;
|
|
}
|
|
|
|
function spawnSyncGen(prompt, out, size = null) {
|
|
const args = [GENERATE, '--prompt', prompt, '--out', out];
|
|
if (size) args.push('--size', size);
|
|
return spawnSync(process.execPath, args, {
|
|
env: { ...process.env, IMPECCABLE_IMAGE_GEN_FAKE: '1' },
|
|
encoding: 'buffer',
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// serve-question interactive cycles
|
|
// --------------------------------------------------------------------------
|
|
describe('new-work-e2e: serve-question decision page', () => {
|
|
it('(a) pick assigned returns the option, hero/board fields, and the CHOSEN CARD directive', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'pick';
|
|
const hero = makeFakeImage(cwd, 'Fillmore handbill hero', 'hero.png');
|
|
const board = makeFakeImage(cwd, 'Fillmore handbill board', 'board.png');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
question: 'The roll assigned Fillmore Handbill.',
|
|
options: [
|
|
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', hero, board },
|
|
{ id: 'challenger-teletext', label: 'Teletext Service', body: 'block-mosaic pages' },
|
|
],
|
|
reroll: true,
|
|
canon: true,
|
|
steer: true,
|
|
};
|
|
await startDaemon(cwd, payload, key);
|
|
try {
|
|
const bot = await runUserBot({
|
|
workspaceDir: cwd, key, browser,
|
|
policy: [{ pick: 'assigned', steer: 'warmer palette' }],
|
|
});
|
|
assert.equal(bot.results[0].action, 'pick');
|
|
const collected = await waitLoop(cwd, key);
|
|
assert.equal(collected.code, 0, collected.out);
|
|
assert.match(collected.out, /ANSWER: /);
|
|
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.equal(answer.optionId, 'assigned');
|
|
assert.equal(answer.steer, 'warmer palette');
|
|
assert.ok(answer.hero, 'answer carries the chosen hero path');
|
|
assert.ok(answer.board, 'answer carries the chosen board path');
|
|
assert.match(collected.out, /CHOSEN CARD:/);
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(b) re-roll with steer keeps the server alive; --update re-deals; the next pick is terminal', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'reroll';
|
|
const payload1 = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' },
|
|
{ id: 'challenger-a', label: 'Alt One' },
|
|
],
|
|
reroll: true, steer: true, canon: true,
|
|
};
|
|
const payload2 = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{ id: 'assigned', label: 'Second Hand', kicker: 'THE ROLL' },
|
|
{ id: 'challenger-b', label: 'Alt Two' },
|
|
],
|
|
reroll: true, steer: true,
|
|
};
|
|
await startDaemon(cwd, payload1, key);
|
|
try {
|
|
// Bot drives the whole page: re-roll (with steer) then, after the page
|
|
// reloads into the next hand, pick the assigned card.
|
|
const botPromise = runUserBot({
|
|
workspaceDir: cwd, key, browser,
|
|
policy: [{ reroll: true, steer: 'colder, more restraint' }, { pick: 'assigned' }],
|
|
});
|
|
|
|
// First answer: the re-roll. Server must stay alive afterwards.
|
|
const first = await waitLoop(cwd, key);
|
|
assert.equal(first.code, 0, first.out);
|
|
assert.match(first.out, /"optionId":"reroll"/);
|
|
assert.match(first.out, /colder, more restraint/);
|
|
assert.ok(existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
|
'server state file survives a re-roll');
|
|
|
|
// Deliver the next hand; the live page reloads itself.
|
|
const nextPayloadPath = path.join(cwd, 'next.json');
|
|
writeFileSync(nextPayloadPath, JSON.stringify(payload2));
|
|
const updated = await run(['--update', '--key', key, '--payload', nextPayloadPath], cwd);
|
|
assert.equal(updated.code, 0, updated.out);
|
|
|
|
// Second answer: the terminal pick on the re-dealt hand.
|
|
const second = await waitLoop(cwd, key);
|
|
assert.equal(second.code, 0, second.out);
|
|
assert.match(second.out, /"optionId":"assigned"/);
|
|
|
|
const bot = await botPromise;
|
|
assert.equal(bot.results[0].action, 'reroll');
|
|
assert.ok(bot.results[0].reloaded, 'page reloaded into the next hand');
|
|
assert.equal(bot.results[1].action, 'pick');
|
|
|
|
// Terminal pick cleans the state file up.
|
|
assert.ok(!existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
|
'terminal pick removes the server state file');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(c) canon click returns optionId canon and prints the CANON CHOSEN directive', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'canon';
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
|
|
reroll: true, canon: true, steer: true,
|
|
};
|
|
await startDaemon(cwd, payload, key);
|
|
try {
|
|
await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ canon: true }] });
|
|
const collected = await waitLoop(cwd, key);
|
|
assert.equal(collected.code, 0, collected.out);
|
|
assert.match(collected.out, /"optionId":"canon"/);
|
|
assert.match(collected.out, /CANON CHOSEN:/);
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(d) closing the tab makes --wait exit 4 PAGE CLOSED', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'close';
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
|
|
reroll: true, steer: true,
|
|
};
|
|
await startDaemon(cwd, payload, key);
|
|
try {
|
|
const bot = await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ close: true }] });
|
|
assert.equal(bot.results[0].action, 'close');
|
|
assert.ok(bot.results[0].beat > 0, 'a heartbeat landed before the tab closed');
|
|
// --wait must observe the stale heartbeat and report the closed page.
|
|
const res = await run(['--wait', '--key', key, '--poll', '30'], cwd);
|
|
assert.equal(res.code, 4, `expected exit 4, got ${res.code}: ${res.out}`);
|
|
assert.match(res.out, /PAGE CLOSED/);
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(e) a text-only assigned card caps every card at thumb imagery (salience parity)', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'textonly';
|
|
const hero = makeFakeImage(cwd, 'has a hero', 'hero.png');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'Text Only Direction', body: 'a grounded direction, no comp',
|
|
viewport: 'A full-width ruled manifest with entries.', case: 'Grounded in the audience world.',
|
|
},
|
|
{ id: 'challenger-hero', label: 'Has A Card', hero },
|
|
],
|
|
reroll: true, steer: true,
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('button.choose');
|
|
const textOnlyMedia = await page.$('.card[data-id="assigned"] .media');
|
|
const heroMedia = await page.$('.card[data-id="challenger-hero"] .media');
|
|
const textOnlyFace = await page.$('.card[data-id="assigned"] .face.text-only');
|
|
// No media means no back face to hide facts on: the full read renders
|
|
// on the front, where nothing else would have used the room.
|
|
const textOnlyBack = await page.$('.card[data-id="assigned"] .face.back');
|
|
const frontRead = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent);
|
|
// Salience parity: with a text-only assigned card, a challenger's
|
|
// catalog art may not render as a full-bleed face beside it; it demotes
|
|
// to a labeled thumb in the body, so pretty pixels never outvote the
|
|
// weighing. The thumb stays labeled as reference, never as the promise
|
|
// of the build.
|
|
const heroThumbLabel = await page.$eval('.card[data-id="challenger-hero"] .inspo figcaption', (el) => el.textContent);
|
|
await context.close();
|
|
assert.equal(textOnlyMedia, null, 'text-only card has no .media region');
|
|
assert.ok(textOnlyFace, 'text-only card carries the .text-only face class');
|
|
assert.equal(heroMedia, null, 'parity demotes the challenger hero from a full-bleed face');
|
|
assert.equal(textOnlyBack, null, 'text-only card has no unreachable back face');
|
|
assert.match(frontRead, /First viewport/, 'text-only front carries the first viewport fact');
|
|
assert.match(frontRead, /The case/, 'text-only front carries the case fact');
|
|
assert.equal(heroThumbLabel, 'inspired by', 'demoted catalog art is a labeled thumb');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(e1) a missing comp without inspiration settles into a complete text-only card', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'missing-comp';
|
|
const board = makeFakeImage(cwd, 'board fallback', 'board-fallback.png');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'Shotengai Chalkboard', kicker: 'THE ROLL',
|
|
thesis: 'A hand-lettered neighborhood noticeboard.',
|
|
body: 'Program notes explain how the board changes through the day.',
|
|
palette: ['#18251d', '#f2e8cb'], materials: ['chalk', 'painted timber'],
|
|
viewport: 'The daily program fills a ruled community board.',
|
|
case: 'The audience already reads this visual language.',
|
|
risk: 'Can become nostalgic if the type loses discipline.',
|
|
comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'missing.webp'),
|
|
},
|
|
{
|
|
id: 'kept-only', label: 'Kept Line Only',
|
|
kept: 'Keep the hand-painted timetable as the organizing device.',
|
|
comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'kept-missing.webp'),
|
|
},
|
|
{
|
|
id: 'body-only', label: 'Body Line Only',
|
|
thesis: 'A compact neighborhood notice.',
|
|
body: 'Body copy must remain present exactly once.',
|
|
comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'body-missing.webp'),
|
|
},
|
|
{
|
|
id: 'plain-body', label: 'Plain Body',
|
|
body: 'Plain body follows the promoted facts.',
|
|
viewport: 'A compact neighborhood notice fills the first viewport.',
|
|
comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'plain-body-missing.webp'),
|
|
},
|
|
{
|
|
id: 'board-only', label: 'Board Fallback',
|
|
board,
|
|
comp: path.join(cwd, '.impeccable', 'mocks', 'decision', 'board-missing.webp'),
|
|
},
|
|
],
|
|
reroll: true,
|
|
steer: true,
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
await context.addInitScript(() => {
|
|
const realNow = Date.now.bind(Date);
|
|
let offset = 0;
|
|
Date.now = () => realNow() + offset;
|
|
window.__advanceDecisionClock = (ms) => { offset += ms; };
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('.card[data-id="assigned"] .media.comp-pending');
|
|
|
|
await page.evaluate(() => window.__advanceDecisionClock(241000));
|
|
await page.waitForFunction(() => ['assigned', 'kept-only', 'body-only', 'plain-body'].every((id) =>
|
|
!document.querySelector(`.card[data-id="${id}"] .media`)), null, { timeout: 6000 });
|
|
await page.waitForSelector('.card[data-id="board-only"] .media.stand-in');
|
|
|
|
const faceClass = await page.getAttribute('.card[data-id="assigned"] .face.front', 'class');
|
|
const back = await page.$('.card[data-id="assigned"] .face.back');
|
|
const frontRead = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent);
|
|
const keptOnlyRead = await page.$eval('.card[data-id="kept-only"] .face.front', (el) => el.textContent);
|
|
const bodyOnlyRead = await page.$eval('.card[data-id="body-only"] .face.front', (el) => el.textContent);
|
|
const plainBodyOrder = await page.$$eval('.card[data-id="plain-body"] .body > .fact, .card[data-id="plain-body"] .body > .detail',
|
|
(els) => els.map((el) => el.className));
|
|
const boardFallbackLabel = await page.$eval('.card[data-id="board-only"] .stand-in-label', (el) => el.textContent);
|
|
await context.close();
|
|
|
|
assert.match(faceClass, /text-only/, 'the settled card uses the text-only layout');
|
|
assert.equal(back, null, 'the settled card has no unreachable back face');
|
|
assert.match(frontRead, /First viewport/, 'the full first-viewport fact moves to the front');
|
|
assert.match(frontRead, /The case/, 'the full case moves to the front');
|
|
assert.match(frontRead, /Risk/, 'the risk remains readable after the media collapses');
|
|
assert.match(frontRead, /Program notes explain/, 'thesis-linked body prose moves off the removed back face');
|
|
assert.match(keptOnlyRead, /Keep the hand-painted timetable/, 'kept-only content survives without a back face');
|
|
assert.equal(bodyOnlyRead.match(/Body copy must remain present exactly once\./g)?.length, 1,
|
|
'body prose already on a no-back front is not duplicated');
|
|
assert.deepEqual(plainBodyOrder, ['fact', 'detail'],
|
|
'thesis-less body prose follows the facts promoted from the missing comp');
|
|
assert.equal(boardFallbackLabel, 'inspiration · comp pending', 'board-only art remains as the labeled fallback');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(e2) verdicts route the deck: declined cards demote, reorder to the end, and stay adoptable', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'verdicts';
|
|
const hero = makeFakeImage(cwd, 'declined hero', 'declined-hero.png');
|
|
const winnerHero = makeFakeImage(cwd, 'winner hero', 'winner-hero.png');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'The Seedsman Catalog', kicker: 'THE ROLL', hero: winnerHero,
|
|
raised: [{ from: 'challenger-deepsea', raise: 'The catalog now owns its whole viewport.' }],
|
|
},
|
|
// Declined dealt before a competitive card on purpose: the page owns
|
|
// the reorder, so payload order cannot promote a demoted world.
|
|
{
|
|
id: 'challenger-deepsea', label: 'Deep Sea Survey', verdict: 'declined',
|
|
case: 'Fuses poorly: buyers do not identify with abyssal instrumentation.',
|
|
kept: 'Total environmental commitment.', hero,
|
|
// A stray comp on a declined card must not re-promote it to a
|
|
// full media face; the renderer ignores it outright. Declared with
|
|
// the legacy `sketch` key, which doubles as alias coverage.
|
|
sketch: '.impeccable/sketches/challenger-deepsea.webp',
|
|
},
|
|
{ id: 'challenger-waxprint', label: 'Wax Print Market', verdict: 'competitive', hero: winnerHero },
|
|
],
|
|
reroll: true, steer: true,
|
|
canon: true,
|
|
canonCard: { label: 'The category standard', thesis: 'What the category ships.' },
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('button.choose');
|
|
const order = await page.$$eval('.card', (cards) => cards.map((c) => c.dataset.id));
|
|
const declinedCard = await page.$('.card.declined[data-id="challenger-deepsea"]');
|
|
const declinedMedia = await page.$('.card[data-id="challenger-deepsea"] .media');
|
|
const declinedThumb = await page.$('.card[data-id="challenger-deepsea"] .inspo');
|
|
const declinedFront = await page.$eval('.card[data-id="challenger-deepsea"] .face.front', (el) => el.textContent);
|
|
const declinedButton = await page.$eval('.card[data-id="challenger-deepsea"] .face.front button.choose', (el) => el.textContent);
|
|
const competitiveMedia = await page.$('.card[data-id="challenger-waxprint"] .media');
|
|
const raise = await page.$eval('.card[data-id="assigned"] .raise', (el) => el.textContent);
|
|
// A demoted card is still a real choice: adopting it must answer.
|
|
await page.click('.card[data-id="challenger-deepsea"] .face.front button.choose');
|
|
const collected = await waitLoop(cwd, key);
|
|
await context.close();
|
|
assert.equal(collected.code, 0, collected.out);
|
|
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.deepEqual(order, ['assigned', 'challenger-waxprint', 'canon', 'challenger-deepsea'], 'contenders, then the canon, then declined dead last');
|
|
assert.ok(declinedCard, 'declined verdict adds the .declined card class');
|
|
assert.equal(declinedMedia, null, 'declined catalog art never renders full-bleed');
|
|
assert.ok(declinedThumb, 'declined catalog art rides as a labeled thumb');
|
|
assert.match(declinedFront, /Kept/, 'the declined front carries its kept line');
|
|
assert.equal(declinedButton, 'Adopt anyway', 'the declined action is adopt, not build');
|
|
assert.ok(competitiveMedia, 'a competitive challenger keeps its full media face');
|
|
assert.match(raise, /From Deep Sea Survey/, 'the assigned card names its donor');
|
|
assert.equal(answer.optionId, 'challenger-deepsea', 'adopting a declined card answers with its id');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(e3) register steers ride the re-roll: the bolder button answers with its register', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'registers';
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [{ id: 'assigned', label: 'The Seedsman Catalog', kicker: 'THE ROLL' }],
|
|
reroll: { registers: ['safer', 'bolder'] },
|
|
steer: true,
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('#reroll-bolder');
|
|
assert.ok(await page.$('#reroll-safer'), 'the safer steer renders');
|
|
assert.ok(await page.$('#reroll'), 'the plain re-roll stays between the registers');
|
|
await page.click('#reroll-bolder');
|
|
const collected = await waitLoop(cwd, key);
|
|
await context.close();
|
|
assert.equal(collected.code, 0, collected.out);
|
|
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.equal(answer.optionId, 'reroll');
|
|
assert.equal(answer.register, 'bolder', 'the answer names the requested register');
|
|
assert.match(collected.out, /REGISTER: .*bolder/, 'the register directive tells the agent to re-run the seed');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(e4) followup keeps the table open: direction pick, then the execution-contract round', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'followup';
|
|
const directionRound = {
|
|
title: 'Choose the visual world',
|
|
options: [{ id: 'assigned', label: 'The Seedsman Catalog', kicker: 'THE ROLL' }],
|
|
reroll: true, steer: true,
|
|
followup: true,
|
|
};
|
|
const buildPathRound = {
|
|
title: 'How should it be built?',
|
|
question: 'Same direction, two execution contracts. Pick where the risk goes.',
|
|
options: [
|
|
{ id: 'comp-led', label: 'Comp-led', kicker: 'BOLD', thesis: 'A first-viewport comp leads and the build matches it.', risk: 'Fix rounds expected; motion arrives last.' },
|
|
{ id: 'code-led', label: 'Code-led', kicker: 'CRAFTED', thesis: 'Code authors the page; the boards calibrate it.', risk: 'Composition stays closer to convention.' },
|
|
],
|
|
};
|
|
const { url } = await startDaemon(cwd, directionRound, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('button.choose');
|
|
await page.click('.card[data-id="assigned"] .face.front button.choose');
|
|
|
|
// First answer: the direction. Not terminal, and it says so.
|
|
const first = await waitLoop(cwd, key);
|
|
assert.equal(first.code, 0, first.out);
|
|
const firstAnswer = JSON.parse(first.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.equal(firstAnswer.optionId, 'assigned');
|
|
assert.equal(firstAnswer.followup, true, 'the answer marks the table as still open');
|
|
assert.match(first.out, /FOLLOWUP OPEN:/, 'the directive tells the agent to send the next round');
|
|
assert.ok(existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
|
'server state file survives a followup pick');
|
|
|
|
// The page swapped to the loading hand instead of goodbye.
|
|
await page.waitForSelector('.card.skeleton');
|
|
|
|
// Deliver the execution-contract round; the page reloads into it.
|
|
const nextPayloadPath = path.join(cwd, 'build-path.json');
|
|
writeFileSync(nextPayloadPath, JSON.stringify(buildPathRound));
|
|
const updated = await run(['--update', '--key', key, '--payload', nextPayloadPath], cwd);
|
|
assert.equal(updated.code, 0, updated.out);
|
|
await page.waitForSelector('.card[data-id="code-led"]');
|
|
await page.click('.card[data-id="code-led"] .face.front button.choose');
|
|
|
|
// Second answer: terminal, table cleaned up.
|
|
const second = await waitLoop(cwd, key);
|
|
await context.close();
|
|
assert.equal(second.code, 0, second.out);
|
|
const secondAnswer = JSON.parse(second.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.equal(secondAnswer.optionId, 'code-led');
|
|
assert.equal(secondAnswer.followup, undefined, 'the build-path round is terminal');
|
|
assert.ok(!existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
|
'the terminal pick removes the server state file');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(h) the build-path toggle: a code-to-comp flip shimmers the slots, surfaces through --wait, and rides the answer', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'bptoggle';
|
|
const payload = {
|
|
title: 'Choose the structure',
|
|
buildPath: { value: 'code', toggle: true },
|
|
steer: true,
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'The Ledger Spine', kicker: 'THE ROLL',
|
|
comp: '.impeccable/mocks/decision/assigned.webp',
|
|
wireframe: { regions: [{ label: 'rail', x: 0, y: 0, w: 3, h: 10 }, { label: 'rows', x: 3, y: 0, w: 9, h: 10 }] },
|
|
},
|
|
],
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
assert.equal(await page.$('.media.comp-pending'), null, 'code-led serves no shimmer');
|
|
assert.ok(await page.$('.media.wire'), 'code-led serves the wireframe');
|
|
await page.click('.bp-opt[data-bp="comp"]');
|
|
await page.waitForSelector('#bp-confirm:not([hidden])');
|
|
assert.equal(await page.$('.media.comp-pending'), null, 'nothing renders before the flip is confirmed');
|
|
await page.click('#bp-confirm [data-confirm]');
|
|
await page.waitForSelector('.media.comp-pending');
|
|
const flipped = await waitLoop(cwd, key, { poll: 10 });
|
|
assert.equal(flipped.code, 0, flipped.out);
|
|
assert.match(flipped.out, /BUILD PATH FLIPPED: comp/, 'the flip surfaces through --wait');
|
|
assert.doesNotMatch(flipped.out, /ANSWER:/, 'a flip is not an answer');
|
|
// The agent generates the comp; the shimmer swaps for it.
|
|
mkdirSync(path.join(cwd, '.impeccable', 'mocks', 'decision'), { recursive: true });
|
|
makeFakeImage(path.join(cwd, '.impeccable', 'mocks', 'decision'), 'ledger spine comp', 'assigned.webp');
|
|
await page.waitForSelector('.card[data-id="assigned"] .media img.comp:not([hidden])', { timeout: 15000 });
|
|
await page.click('.card[data-id="assigned"] .face.front button.choose');
|
|
const collected = await waitLoop(cwd, key, { poll: 10 });
|
|
await context.close();
|
|
assert.equal(collected.code, 0, collected.out);
|
|
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
|
|
assert.equal(answer.buildPath, 'comp', 'the answer carries the flipped path');
|
|
assert.equal(answer.buildPathFlipped, true, 'the answer marks the flip');
|
|
assert.match(collected.out, /BUILD PATH: comp \(flipped/, 'the directive states session-only scope');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The flip used to be tested only on a wireframe card, where the schematic
|
|
// is hidden and a fresh slot inserted. A card carrying catalog art instead
|
|
// took the other branch: the inspiration stayed the face and the comp slot
|
|
// was inserted below it, so the card showed two stacked images. Comp-first
|
|
// demotes the inspiration to the corner, and a flip has to reach the same
|
|
// shape a comp-first render would have served.
|
|
it('(f2) flipping to comp demotes an inspiration face to the corner instead of stacking a second slot', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'flipinspo';
|
|
const hero = makeFakeImage(cwd, 'catalog inspiration', 'inspo.png');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
buildPath: { value: 'code', toggle: true },
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'The Ledger Spine', kicker: 'THE ROLL', hero,
|
|
comp: '.impeccable/mocks/decision/assigned.webp',
|
|
viewport: 'A ruled masthead over a dense grid.', risk: 'Reads editorial.',
|
|
},
|
|
],
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
|
|
const front = '.card[data-id="assigned"] .face.front';
|
|
// Code-led: the catalog art is the face, labeled so it never reads as a promise.
|
|
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1, 'one media slot before the flip');
|
|
assert.ok(await page.$(`${front} .media .media-label`), 'the inspiration face is labeled');
|
|
assert.equal(await page.$(`${front} .pip`), null, 'no corner inspiration while code-led');
|
|
|
|
await page.click('.bp-opt[data-bp="comp"]');
|
|
await page.waitForSelector('#bp-confirm:not([hidden])');
|
|
await page.click('#bp-confirm [data-confirm]');
|
|
await page.waitForSelector(`${front} .media.comp-pending`);
|
|
|
|
// The whole point: one slot, not two.
|
|
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1,
|
|
'the flip converts the inspiration slot rather than stacking a second one');
|
|
assert.ok(await page.$(`${front} .media.comp-pending .pip img`), 'the inspiration moved into the corner');
|
|
assert.equal(await page.$(`${front} .media > .media-label`), null, 'it is no longer presented as the face');
|
|
assert.ok(await page.$(`${front} .media .chip.expand`), 'the converted slot keeps its expand affordance');
|
|
|
|
const flipped = await waitLoop(cwd, key, { poll: 10 });
|
|
assert.match(flipped.out, /BUILD PATH FLIPPED: comp/);
|
|
|
|
// A comp that streams in must be openable. The zoom handlers used to be
|
|
// bound once at deal time, so anything built by the flip was inert.
|
|
mkdirSync(path.join(cwd, '.impeccable', 'mocks', 'decision'), { recursive: true });
|
|
makeFakeImage(path.join(cwd, '.impeccable', 'mocks', 'decision'), 'ledger spine comp', 'assigned.webp');
|
|
await page.waitForSelector(`${front} .media img.comp:not([hidden])`, { timeout: 15000 });
|
|
await page.click(`${front} .media .chip.expand`);
|
|
await page.waitForSelector('#lightbox:not([hidden])');
|
|
const compSrc = await page.$eval('#lightbox img', (img) => img.getAttribute('src'));
|
|
assert.ok(compSrc, 'the streamed-in comp opens full screen');
|
|
await page.click('#lightbox');
|
|
await page.waitForSelector('#lightbox', { state: 'hidden' });
|
|
|
|
// The corner inspiration opens the catalog art, not the comp behind it.
|
|
// Both zoom targets are delegated to document, where stopPropagation
|
|
// cannot stop a sibling listener, so split handlers let the media one
|
|
// overwrite the lightbox the pip had just filled.
|
|
const cornerSrc = await page.$eval(`${front} .media .pip img`, (img) => img.getAttribute('src'));
|
|
await page.click(`${front} .media .pip`);
|
|
await page.waitForSelector('#lightbox:not([hidden])');
|
|
const pipSrc = await page.$eval('#lightbox img', (img) => img.getAttribute('src'));
|
|
assert.notEqual(pipSrc, compSrc, 'the corner opens the inspiration, not the comp');
|
|
assert.equal(pipSrc, cornerSrc, 'and it is exactly the art in the corner');
|
|
await page.click('#lightbox');
|
|
await page.waitForSelector('#lightbox', { state: 'hidden' });
|
|
|
|
// Flipping back keeps a comp that already landed, by design, and must
|
|
// still leave exactly one slot. exitComp is synchronous, so there is
|
|
// nothing to wait for.
|
|
await page.click('.bp-opt[data-bp="code"]');
|
|
assert.equal(await page.$$eval(`${front} .media`, (els) => els.length), 1, 'still one slot after flipping back');
|
|
assert.ok(await page.$(`${front} .media img.comp:not([hidden])`), 'the landed comp survives the flip back');
|
|
await context.close();
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(g) a wireframe card draws its schematic in the media slot and keeps its full read on the front', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'wire';
|
|
const payload = {
|
|
title: 'Choose the structure',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'The Ledger Spine', kicker: 'THE ROLL',
|
|
viewport: 'An alphabetical spine with a sticky category rail.',
|
|
wireframe: { cols: 12, rows: 10, regions: [
|
|
{ label: 'category rail', x: 0, y: 0, w: 3, h: 10, accent: true },
|
|
{ label: 'ledger rows', x: 3, y: 0, w: 9, h: 10 },
|
|
] },
|
|
},
|
|
{ id: 'alt', label: 'The Switchboard', wireframe: { regions: [{ label: 'board', x: 0, y: 0, w: 12, h: 10 }] } },
|
|
],
|
|
reroll: true,
|
|
steer: true,
|
|
};
|
|
await startDaemon(cwd, payload, key);
|
|
const state = JSON.parse(readFileSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`), 'utf8'));
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(state.url, { waitUntil: 'load' });
|
|
const regions = await page.$$eval('.card[data-id="assigned"] .wire-region', (els) => els.length);
|
|
const accent = await page.$('.card[data-id="assigned"] .wire-region.accent');
|
|
const back = await page.$('.card[data-id="assigned"] .face.back');
|
|
const front = await page.$eval('.card[data-id="assigned"] .face.front', (el) => el.textContent);
|
|
await context.close();
|
|
assert.equal(regions, 2, 'both wireframe regions render');
|
|
assert.ok(accent, 'the accent region carries its class');
|
|
assert.equal(back, null, 'a wireframe card has no back; the full read stays on the front');
|
|
assert.match(front, /alphabetical spine/, 'the front keeps the full read beside the schematic');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('(f) a hero that never loads collapses to a labeled palette field, not a dark void', async () => {
|
|
const cwd = makeWorkspace();
|
|
const key = 'brokenart';
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'Broken Art Direction',
|
|
thesis: 'The art is gone but the direction is not.',
|
|
palette: ['#1a3a2e', '#f2ede2', '#c8452a'],
|
|
risk: 'None.',
|
|
viewport: 'A ruled manifest.', case: 'Back facts must stay reachable.',
|
|
// Port 1 refuses connections everywhere; the load fails offline
|
|
// and deterministically, like a retired catalog URL in the field.
|
|
hero: 'http://127.0.0.1:1/gone-hero.webp',
|
|
},
|
|
{
|
|
id: 'challenger-bare', label: 'No Palette Direction',
|
|
thesis: 'Broken art and no swatches to paint from.',
|
|
hero: 'http://127.0.0.1:1/gone-too.webp',
|
|
},
|
|
],
|
|
reroll: true, steer: true,
|
|
};
|
|
const { url } = await startDaemon(cwd, payload, key);
|
|
try {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto(url, { waitUntil: 'load' });
|
|
await page.waitForSelector('.card[data-id="assigned"] .media.unavailable');
|
|
await page.waitForSelector('.card[data-id="challenger-bare"] .media.unavailable');
|
|
const imgGone = await page.$('.card[data-id="assigned"] .media img');
|
|
const label = await page.$eval('.card[data-id="assigned"] .media .media-label', (el) => el.textContent);
|
|
const field = await page.$eval('.card[data-id="assigned"] .media', (el) => el.style.background);
|
|
// The stale "Inspiration" tooltip goes with the art it described.
|
|
const title = await page.$eval('.card[data-id="assigned"] .media', (el) => el.getAttribute('title'));
|
|
// The scrim must not trap the flip controls: Details still flips.
|
|
await page.click('.card[data-id="assigned"] .chip.flip');
|
|
const flipped = await page.$('.card[data-id="assigned"].flipped');
|
|
// A palette-less card keeps the label and leans on the CSS graphite
|
|
// field instead of an inline gradient.
|
|
const bareLabel = await page.$eval('.card[data-id="challenger-bare"] .media .media-label', (el) => el.textContent);
|
|
const bareField = await page.$eval('.card[data-id="challenger-bare"] .media', (el) => el.style.background);
|
|
await context.close();
|
|
assert.equal(imgGone, null, 'the broken img is removed');
|
|
assert.equal(label, 'artwork unavailable', 'the slot says what happened');
|
|
assert.match(field, /linear-gradient/, 'the slot is painted from the card palette');
|
|
assert.equal(title, null, 'the inspiration tooltip is removed with the art');
|
|
assert.ok(flipped, 'the Details chip still flips the card under the scrim');
|
|
assert.equal(bareLabel, 'artwork unavailable', 'a palette-less slot still says what happened');
|
|
assert.equal(bareField, '', 'a palette-less slot takes the CSS fallback field, no inline gradient');
|
|
} finally {
|
|
await stopDaemon(cwd, key);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Fake image generation
|
|
// --------------------------------------------------------------------------
|
|
describe('new-work-e2e: fake image generation', () => {
|
|
it('is deterministic per prompt and encodes the SYNTHETIC marker', () => {
|
|
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
|
try {
|
|
const a = path.join(cwd, 'a.png');
|
|
const b = path.join(cwd, 'b.png');
|
|
const r1 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
|
|
const r2 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', b);
|
|
assert.equal(r1.status, 0, r1.stderr?.toString());
|
|
assert.equal(r2.status, 0, r2.stderr?.toString());
|
|
assert.ok(existsSync(a) && existsSync(b), 'both files exist');
|
|
assert.match(r1.stdout.toString(), /\$0\.00/, 'cost line reads $0.00');
|
|
const bytesA = readFileSync(a);
|
|
const bytesB = readFileSync(b);
|
|
assert.ok(bytesA.equals(bytesB), 'same prompt yields identical bytes');
|
|
// Valid PNG signature + the SYNTHETIC marker (in the tEXt chunk).
|
|
assert.equal(bytesA.slice(0, 8).toString('hex'), '89504e470d0a1a0a');
|
|
assert.ok(bytesA.includes(Buffer.from('SYNTHETIC')), 'PNG carries the SYNTHETIC marker');
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('renders a different palette for a different prompt', () => {
|
|
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
|
try {
|
|
const a = path.join(cwd, 'a.png');
|
|
const c = path.join(cwd, 'c.png');
|
|
spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
|
|
spawnSyncGen('Teletext broadcast mosaic, cold blue', c);
|
|
const bytesA = readFileSync(a);
|
|
const bytesC = readFileSync(c);
|
|
assert.ok(!bytesA.equals(bytesC), 'different prompts produce different images');
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('the SVG variant carries the readable prompt text and SYNTHETIC COMP label', () => {
|
|
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
|
try {
|
|
const svg = path.join(cwd, 'comp.svg');
|
|
const res = spawnSyncGen('teletext broadcast mosaic', svg, '800x600');
|
|
assert.equal(res.status, 0, res.stderr?.toString());
|
|
const text = readFileSync(svg, 'utf8');
|
|
assert.match(text, /^<\?xml/, 'is an SVG document');
|
|
assert.match(text, /SYNTHETIC COMP/);
|
|
assert.match(text, /teletext/i, 'the prompt text is rendered');
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|