mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
* Polish the decision page: raise cycler, declined height, canon order, full card anatomy Field feedback from the first real rolls of the verdict-routed hand: - Several raises stacked on the assigned card blew it out of proportion. More than one raise now renders as a compact cycler: one visible, a counter, click or Enter advances. A single raise stays inline. - Declined cards inherited the row's stretch alignment, so a narrow card stood at the tallest contender's height, a strange stilt beside the hand. They now size to their content. - Deck order becomes a gradient of standing: contenders, then the canon, then declined dead last. The canon between full alternates and the demoted row reads as the familiar door rather than the last resort after the rejects. - Root cause of bare-bones challenger and canon cards in the field: the --schema example only gave the assigned card palette, materials, and risk, and models author payloads by imitating the example, so the "same anatomy on every card" instruction lost to it every time. The example now carries full anatomy on every card and the schema note says a card with no palette chips is an authoring gap, not a data gap. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> * Decision cards carry full-fidelity comps instead of sketches Field verdict on the sketch contract: the sketches came back too simple to inform the choice, and generation takes the same time at any fidelity, so the deliberately-unfinished frame paid comp cost for sketch quality. The decision card's image is now that direction's north-star comp, produced under visualize.md's comp discipline (structure-led prompt, real name and content, no invented commercial claims), saved under .impeccable/mocks/ with its prompt sidecar. Fairness between cards comes from equal fidelity in each card's own grammar rather than shared unfinishedness. The chosen card's comp is never spent by the choice: on a comp-led build it enters the comp round as compositional option one (visualize.md now generates two variations beside it; a round arriving with no decision comp still renders all three), and on a code-led build it returns at the finish review as the critique reference. Produce order still front-loads a re-roll's spend onto the cards read first. serve-question keeps the sketch field's wire name for payload compatibility; docs, schema paths, shimmer labels, and the answer directive (CHOSEN COMP) speak comp. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: address PR review bot findings on the comp round - Producer still forced sketches (cursor, high): the asset producer's Decision Sketches contract still mandated deliberately unfinished matte sketches, so the parallel path would keep shipping sketch-era images. The section is now Decision Comps: full-fidelity north-star comp, structure-led prompt, equal commitment across siblings, no invented claims, sidecar written. - Mocks collided with the approval check (cursor, high): decision comps now live under .impeccable/mocks/decision/, visualize.md scopes the no-approval finding to comp-round output, new-work.md states the unchosen hand implies no approval, and the code-led finish packet names the chosen decision comp as the critique reference in the approved-comp slot. - Raise cycler announces (greptile, both P1s): a visually hidden aria-live region reads out the newly active raise and its position on advance; initial render stays quiet. - Declined width in the vertical deck (cursor, medium): align-self: flex-start shrank declined cards to content width in the portrait column layout, where the cross axis is horizontal; they stretch there and keep content height in the row layout. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: raise cycler tooltip and label name both input modes Copilot: the tooltip said Click while the control also answers Enter and Space; the title and a new aria-label now say activate/press Enter. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: finish reviewer exempts decision comps from the approval check cursor[bot] follow-through: the reviewer's Persistence check still treated any comps under .impeccable/mocks/ as approval-gated, and the reviewer never reads visualize.md by design, so code-led and spent-hand rounds could draw a false skipped-approval finding. The check now scopes to comp-round comps, exempts .impeccable/mocks/decision/ as the direction round's dealt hand, and defines how a code-led build's decision comp is judged in the approved-comp slot: the critique reference, under the no-approved-comp fidelity rules plus what the image dared that the build did not. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: the critique reference is its own reviewer input, not the approved-comp slot cursor[bot]: passing the code-led decision comp through the approved-comp slot dragged in that slot's obligations (inventory-first reading, the fidelity matrix, Truth's shipped-asset demand for every image-native region), which contradicts code-led's premise. The input contract now names it a separate labeled critique-reference input that nothing binding "the approved comp" touches, and Fidelity defines its treatment where the no-approved-comp rules live: provocation, not spec; no matrix, citations, or asset obligations; its dares enter material_fixes as ordinary fixes. AI-assisted change. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
254 lines
12 KiB
JavaScript
254 lines
12 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
import { browserOpenCommand, openSystemBrowser } from '../skill/scripts/lib/open-system-browser.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'serve-question.mjs');
|
|
|
|
function startServer(payload, extraArgs = []) {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const payloadPath = path.join(dir, 'q.json');
|
|
writeFileSync(payloadPath, JSON.stringify(payload));
|
|
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath, '--no-open', '--timeout', '30', ...extraArgs], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
let out = '';
|
|
const timer = setTimeout(() => reject(new Error(`no URL in output: ${out}`)), 10000);
|
|
child.stdout.on('data', (chunk) => {
|
|
out += chunk;
|
|
const match = out.match(/QUESTION URL: (http:\/\/127\.0\.0\.1:\d+\/)/);
|
|
if (match) { clearTimeout(timer); resolve({ child, url: match[1], read: () => out }); }
|
|
});
|
|
});
|
|
}
|
|
|
|
const PAYLOAD = {
|
|
title: 'Choose the visual world',
|
|
question: 'The roll assigned Fillmore Handbill.',
|
|
options: [
|
|
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 psychedelic handbills' },
|
|
{ id: 'challenger-1', label: 'Teletext Service', body: 'block-mosaic broadcast pages' },
|
|
],
|
|
reroll: true,
|
|
steer: true,
|
|
};
|
|
|
|
describe('serve-question', () => {
|
|
it('opens Windows URLs through cmd.exe and reserves the start title argument', () => {
|
|
assert.deepEqual(
|
|
browserOpenCommand('http://127.0.0.1:1234/', { platform: 'win32', comspec: 'cmd.exe' }),
|
|
{ command: 'cmd.exe', args: ['/c', 'start', '', 'http://127.0.0.1:1234/'] },
|
|
);
|
|
});
|
|
|
|
it('absorbs asynchronous system-opener failures after printing the URL', () => {
|
|
const child = new EventEmitter();
|
|
child.unref = () => {};
|
|
assert.equal(openSystemBrowser('http://127.0.0.1:1234/', {
|
|
platform: 'linux',
|
|
spawnImpl: () => child,
|
|
}), true);
|
|
assert.equal(child.listenerCount('error'), 1);
|
|
assert.doesNotThrow(() => child.emit('error', Object.assign(new Error('missing opener'), { code: 'ENOENT' })));
|
|
});
|
|
|
|
it('serves the page, records the answer, prints ANSWER, exits 0', async () => {
|
|
const { child, url, read } = await startServer(PAYLOAD);
|
|
const html = await (await fetch(url)).text();
|
|
assert.match(html, /Fillmore Handbill/);
|
|
assert.match(html, /THE ROLL/);
|
|
assert.match(html, /Re-roll/);
|
|
const post = await fetch(`${url}answer`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ optionId: 'assigned', steer: 'warmer palette' }),
|
|
});
|
|
assert.equal(post.status, 200);
|
|
const code = await new Promise((resolve) => child.on('exit', resolve));
|
|
assert.equal(code, 0);
|
|
assert.match(read(), /ANSWER: \{"optionId":"assigned","steer":"warmer palette"\}/);
|
|
});
|
|
|
|
it('re-roll answers round-trip with their own id', async () => {
|
|
const { child, url, read } = await startServer(PAYLOAD);
|
|
await fetch(`${url}answer`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ optionId: 'reroll', steer: '' }),
|
|
});
|
|
const code = await new Promise((resolve) => child.on('exit', resolve));
|
|
assert.equal(code, 0);
|
|
assert.match(read(), /"optionId":"reroll"/);
|
|
});
|
|
|
|
it('start/wait cycle: daemonize, poll WAITING, then collect the answer', async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const payloadPath = path.join(dir, 'q.json');
|
|
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
|
const run = (args) => new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
let out = '';
|
|
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
child.on('exit', (code) => resolve({ code, out }));
|
|
});
|
|
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'tk']);
|
|
assert.equal(started.code, 0);
|
|
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
|
assert.ok(url, started.out);
|
|
const waiting = await run(['--wait', '--key', 'tk', '--poll', '1']);
|
|
assert.equal(waiting.code, 3);
|
|
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) });
|
|
const collected = await run(['--wait', '--key', 'tk', '--poll', '5']);
|
|
assert.equal(collected.code, 0);
|
|
assert.match(collected.out, /"optionId":"assigned"/);
|
|
});
|
|
|
|
it('headless detection spares the modes that never open a browser', async () => {
|
|
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
|
|
// that is already running, --stop kills one, --schema just prints text,
|
|
// so a headless environment must not turn any of them into exit 2: the
|
|
// documented flow polls --wait while it exits 3, and new-work.md tells
|
|
// the agent to read --schema first.
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const payloadPath = path.join(dir, 'q.json');
|
|
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
|
const headlessEnv = { ...process.env, CI: '1' };
|
|
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
|
|
const run = (args) => new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, env: headlessEnv, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
let out = '';
|
|
child.stdout.on('data', (chunk) => { out += chunk; });
|
|
child.on('exit', (code) => resolve({ code, out }));
|
|
});
|
|
|
|
const schema = await run(['--schema']);
|
|
assert.equal(schema.code, 0, `--schema under CI must print, got ${schema.code}: ${schema.out}`);
|
|
|
|
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'hk']);
|
|
assert.equal(started.code, 0, started.out);
|
|
try {
|
|
const waiting = await run(['--wait', '--key', 'hk', '--poll', '1']);
|
|
assert.equal(waiting.code, 3, `--wait under CI must report WAITING, got ${waiting.code}: ${waiting.out}`);
|
|
} finally {
|
|
const stopped = await run(['--stop', '--key', 'hk']);
|
|
assert.equal(stopped.code, 0, `--stop under CI must kill the daemon, got ${stopped.code}: ${stopped.out}`);
|
|
}
|
|
});
|
|
|
|
it('headless detection still blocks the path that would open a browser', async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const payloadPath = path.join(dir, 'q.json');
|
|
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
|
const headlessEnv = { ...process.env, CI: '1' };
|
|
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
|
|
const code = await new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath], { cwd: dir, env: headlessEnv, stdio: 'ignore' });
|
|
child.on('exit', resolve);
|
|
});
|
|
assert.equal(code, 2);
|
|
});
|
|
|
|
it('rejects an empty payload', async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const payloadPath = path.join(dir, 'q.json');
|
|
writeFileSync(payloadPath, JSON.stringify({ options: [] }));
|
|
const code = await new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath, '--no-open'], { stdio: 'ignore' });
|
|
child.on('exit', resolve);
|
|
});
|
|
assert.equal(code, 1);
|
|
});
|
|
|
|
it('trusts a fresh heartbeat over a failed kill probe, and still detects true death', async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const qdir = path.join(dir, '.impeccable', 'questions');
|
|
const { mkdirSync } = await import('node:fs');
|
|
mkdirSync(qdir, { recursive: true });
|
|
// Fresh heartbeat + a pid that cannot be signaled (throws like a sandbox
|
|
// EPERM or a recycled pid): the wait must keep WAITING (exit 3), never
|
|
// declare the server gone (exit 2). Pid 1 throws EPERM for a normal user.
|
|
writeFileSync(path.join(qdir, 'beat1.state.json'), JSON.stringify({ pid: 1, port: 1, url: 'http://127.0.0.1:1/', lastBeat: Date.now() }));
|
|
const waiting = await new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, '--wait', '--key', 'beat1', '--poll', '2'], { cwd: dir, stdio: 'ignore' });
|
|
child.on('exit', resolve);
|
|
});
|
|
assert.equal(waiting, 3, 'fresh heartbeat must read as alive regardless of the kill probe');
|
|
// Stale heartbeat + a pid that is genuinely gone (ESRCH): server dead, exit 2.
|
|
writeFileSync(path.join(qdir, 'dead1.state.json'), JSON.stringify({ pid: 999999999 >>> 8, port: 1, url: 'http://127.0.0.1:1/' }));
|
|
const dead = await new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, '--wait', '--key', 'dead1', '--poll', '2'], { cwd: dir, stdio: 'ignore' });
|
|
child.on('exit', resolve);
|
|
});
|
|
assert.equal(dead, 2, 'a truly missing process must still read as gone');
|
|
});
|
|
|
|
it('renders anatomy, streams late sketches, and returns the chosen sketch', async () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
|
const sketchPath = path.join(dir, 'sketches', 'assigned.webp');
|
|
const payload = {
|
|
title: 'Choose the visual world',
|
|
options: [
|
|
{
|
|
id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL',
|
|
thesis: 'The gig poster idea.', palette: ['#e8452c', '#f5d64c'], materials: ['letterpress'],
|
|
viewport: 'Full-bleed dated bill.', risk: 'Nostalgia trap.',
|
|
sketch: sketchPath, hero: 'https://impeccable.style/worlds/cards/x-hero.webp',
|
|
},
|
|
{ id: 'challenger-1', label: 'Teletext Service', case: 'Fuses cleanly.' },
|
|
],
|
|
reroll: true,
|
|
canon: true,
|
|
canonCard: { label: 'The category standard', thesis: 'What the category ships.' },
|
|
steer: true,
|
|
followup: true,
|
|
};
|
|
const { child, url, read } = await startServer(payload);
|
|
const html = await (await fetch(url)).text();
|
|
// Anatomy renders: chips, tags, fact labels, thesis.
|
|
assert.match(html, /swatches/);
|
|
// A blocking server has no update channel, so even a followup payload
|
|
// must not arm the page's loading-hand path (detached mode arms it; the
|
|
// new-work e2e suite covers that side).
|
|
assert.match(html, /const FOLLOWUP = false/);
|
|
assert.match(html, /background:#e8452c/);
|
|
assert.match(html, /class="tag">letterpress/);
|
|
assert.match(html, /The gig poster idea\./);
|
|
assert.match(html, /Fuses cleanly\./);
|
|
// The inspiration image rides picture-in-picture beside the sketch slot.
|
|
assert.match(html, /class="pip"/);
|
|
assert.match(html, /media sketching/);
|
|
// canonCard renders as a subordinate card and suppresses the footer action.
|
|
assert.match(html, /card canon/);
|
|
assert.match(html, /Play it straight</);
|
|
assert.doesNotMatch(html, /<button id="canon"/);
|
|
// The sketch slot 404s until the file lands, then serves it.
|
|
const slot = html.match(/data-sketch="(\/img\/\d+)"/)?.[1];
|
|
assert.ok(slot, 'sketch slot registered before the file exists');
|
|
assert.equal((await fetch(url.replace(/\/$/, '') + slot)).status, 404);
|
|
const { mkdirSync } = await import('node:fs');
|
|
mkdirSync(path.dirname(sketchPath), { recursive: true });
|
|
writeFileSync(sketchPath, 'RIFFxxxxWEBP');
|
|
assert.equal((await fetch(url.replace(/\/$/, '') + slot)).status, 200);
|
|
// The page polls with a cache-busting query; the route must tolerate it.
|
|
assert.equal((await fetch(url.replace(/\/$/, '') + slot + '?t=1')).status, 200);
|
|
// The answer carries the chosen card's sketch for comp seeding.
|
|
await fetch(`${url}answer`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ optionId: 'assigned', steer: '' }),
|
|
});
|
|
const code = await new Promise((resolve) => child.on('exit', resolve));
|
|
assert.equal(code, 0);
|
|
assert.match(read(), /"sketch":/);
|
|
assert.match(read(), /CHOSEN COMP:/);
|
|
});
|
|
});
|