mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c654acb005 | ||
|
|
c7b67b3832 | ||
|
|
1b194d9751 | ||
|
|
e2ef633b95 | ||
|
|
6cbb7ce8d1 | ||
|
|
529184bbe4 | ||
|
|
fc620b9620 | ||
|
|
79656d1ce8 | ||
|
|
f148496f67 | ||
|
|
331c2f2696 | ||
|
|
60c4fa25db | ||
|
|
917d3afcf2 | ||
|
|
4e381305e1 | ||
|
|
c6ac34b929 |
@@ -71,6 +71,15 @@
|
||||
],
|
||||
"createdAt": "2026-06-15T23:37:38.170Z",
|
||||
"reason": "Generic slop card intentionally uses Inter for the before-state comparison"
|
||||
},
|
||||
{
|
||||
"rule": "design-system-font-size",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"skill/scripts/live-browser.js"
|
||||
],
|
||||
"createdAt": "2026-07-17T00:00:00.000Z",
|
||||
"reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -67,6 +67,8 @@ Conventions: wrap the identifying heading text in straight double quotes inside
|
||||
|
||||
Recent history favors short, imperative subjects such as `Fix: ...`, `Add ...`, `Improve ...`, or `Bump ...`. Keep commits focused and explain the user-facing impact when it is not obvious. PRs should summarize what changed, list validation performed, and call out whether generated provider output was intentionally omitted or intentionally refreshed. Include screenshots for visible `site/` changes and mention affected providers when transform behavior changes.
|
||||
|
||||
**Do not bump manifest versions or add changelog entries in a feature PR.** Bumping is a release step: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened yet. Land the code; the maintainer bumps `package.json` / `.claude-plugin/plugin.json` / `extension/manifest.json` and writes `site/pages/changelog.astro` when cutting the release (see **Releases**). The only PR that touches a manifest version is one whose purpose is the release itself.
|
||||
|
||||
## Contributing, Issue, and PR Guidelines
|
||||
|
||||
This repo is issue-first for outside contributions. If you are not `pbakaus` or `abdulwahabone`, do not open a PR unless a maintainer has first discussed the change in an issue and asked for, or clearly approved, a PR. For unapproved work, open an issue or comment on an existing issue and wait for maintainer direction. Unsolicited PRs may be closed without review.
|
||||
|
||||
@@ -68,7 +68,9 @@ Editorial brief is at `docs/STYLE.md`. Read it before editing the homepage, sub-
|
||||
|
||||
The build's `validateProse` step (in `scripts/build.js`) enforces a denylist: em dashes (`—` and HTML entities), the `--` em-dash substitute, `load-bearing`, `highest-leverage`, `biggest unlock`, `seamless`, `robust`, `delve`, `elevate`, `empower`, `underscore`, `pivotal`, `tapestry`, `data-driven`, `reflex defaults`, `collapses into monoculture`, `in today's`, `gone are the days`, `whether you're`, `let's dive in`, `in summary`, `in conclusion`, `moreover`, `furthermore`. Each rule prints a rationale and a suggested replacement when it fires. **Do not silently work around the regex.** If a banned word has earned a real meaning here, raise it as a `docs/STYLE.md` amendment.
|
||||
|
||||
The validator scans `site/pages/`, `site/content/`, `site/components/`, `site/layouts/`, `README.md`, `README.npm.md`. It deliberately skips `skill/` because LLM-facing reference instructions sometimes need technical phrasings the marketing copy can't.
|
||||
`validateProse` scans `site/components/`, `site/content/`, `site/layouts/`, `site/pages/`, `README.md`, `README.npm.md` (extensions `.html`, `.md`, `.js`, `.mjs`, `.css`, `.astro`). It exempts `site/pages/slop/`, because the slop catalog documents every anti-pattern by example and has to contain the specimens.
|
||||
|
||||
**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not `skill/scripts/**` code or comments) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `skill/scripts/*.mjs` code comment does not.
|
||||
|
||||
The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass.
|
||||
|
||||
@@ -248,6 +250,8 @@ bun run build:browser
|
||||
|
||||
## Versioning
|
||||
|
||||
**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself.
|
||||
|
||||
There are three independently versioned components. Only bump the one(s) that actually changed:
|
||||
|
||||
**CLI** (npm package):
|
||||
|
||||
@@ -226,12 +226,14 @@ function addValue(cwd, args) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
if (parsed.files.length) existing.files = parsed.files;
|
||||
} else {
|
||||
// rule, value, files, createdAt, reason — the same order the normalizers emit,
|
||||
// so a fresh entry survives the next write untouched.
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (parsed.files.length) entry.files = parsed.files;
|
||||
entry.createdAt = new Date().toISOString();
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
|
||||
@@ -43,23 +43,48 @@ function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
// CSS named colors whose channels are equal (achromatic). Anything outside
|
||||
// this set falls through to the format parsers, and an unrecognized spelling
|
||||
// stays non-neutral so a real accent is never skipped.
|
||||
const NEUTRAL_COLOR_KEYWORDS = new Set([
|
||||
'transparent', 'currentcolor',
|
||||
'black', 'white', 'gray', 'grey', 'silver',
|
||||
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
|
||||
'gainsboro', 'whitesmoke',
|
||||
]);
|
||||
|
||||
function hexChannels(color) {
|
||||
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
|
||||
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
|
||||
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
|
||||
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutrality test for colors as written in source CSS.
|
||||
*
|
||||
* shared/color.mjs's isNeutralColor only parses the computed function forms a
|
||||
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
|
||||
* other spelling as chromatic so an unknown format is never silently skipped.
|
||||
* That default is wrong for authored CSS, where `#000` and `black` are the
|
||||
* normal spellings: calling it directly reports a plain black hairline as a
|
||||
* colored stripe. Handle hex and named neutrals here, then defer.
|
||||
*/
|
||||
function isNeutralAuthoredColor(rawColor) {
|
||||
const c = String(rawColor || '').trim().toLowerCase();
|
||||
if (!c) return false;
|
||||
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const channels = hexChannels(c);
|
||||
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
||||
if (!m) return false;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
}
|
||||
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
||||
if (shex) {
|
||||
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
}
|
||||
return false;
|
||||
return isNeutralAuthoredColor(m[1]);
|
||||
}
|
||||
|
||||
const REGEX_MATCHERS = [
|
||||
@@ -345,12 +370,88 @@ const REGEX_ANALYZERS = [
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Vue/Svelte <style> blocks)
|
||||
// Structural CSS checks used by source files whose styles are not parsed by
|
||||
// the static HTML engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
|
||||
|
||||
function insetStripeColorIsChromatic(rawColor) {
|
||||
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
|
||||
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
|
||||
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
|
||||
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
|
||||
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
|
||||
return !isNeutralAuthoredColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank out comment bodies while preserving every byte offset (and therefore
|
||||
* every line number) so commented-out CSS is not scanned as live rules.
|
||||
*/
|
||||
function blankCssComments(css) {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
|
||||
const content = blankCssComments(rawContent);
|
||||
const findings = [];
|
||||
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
|
||||
let match;
|
||||
while ((match = ruleRe.exec(content)) !== null) {
|
||||
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
|
||||
// newlines trailing the previous rule. Advance past that run before deriving
|
||||
// the line, or every rule after the first reports the preceding line.
|
||||
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
|
||||
const selector = match[1].trim().replace(/\s+/g, ' ');
|
||||
if (!selector) continue;
|
||||
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
||||
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
|
||||
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
|
||||
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
|
||||
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
|
||||
|
||||
const width = match[2].match(/(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/i);
|
||||
if (width && Number(width[1]) <= 40) continue;
|
||||
const declaration = match[2].match(/(?:^|;)\s*box-shadow\s*:\s*([^;]+)/i);
|
||||
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
|
||||
|
||||
for (const rawLayer of declaration[1].split(/,(?![^(]*\))/)) {
|
||||
const layer = rawLayer.trim();
|
||||
// `inset` is order-independent inside a box-shadow layer: `inset 4px 0 0 red`
|
||||
// and `4px 0 0 red inset` paint the same stripe, and requiring it first
|
||||
// silently missed the second spelling. Strip it only as a standalone
|
||||
// keyword, so a color token such as var(--inset-accent) survives intact;
|
||||
// an unchanged layer had no inset keyword and is not our shape.
|
||||
const body = layer.replace(/(^|\s)inset(?=\s|$)/i, '$1').trim();
|
||||
if (body === layer) continue;
|
||||
const shadow = body.match(/^(-?\d*\.?\d+)(px)?\s+(-?\d*\.?\d+)(px)?\s+(-?\d*\.?\d+)(px)?(?:\s+(-?\d*\.?\d+)(px)?)?\s+(.+)$/i);
|
||||
if (!shadow) continue;
|
||||
const x = Number(shadow[1]);
|
||||
const y = Number(shadow[3]);
|
||||
const blur = Number(shadow[5]);
|
||||
const spread = shadow[7] == null ? 0 : Number(shadow[7]);
|
||||
if ((x !== 0 && !shadow[2]) || (y !== 0 && !shadow[4]) || blur !== 0 || spread !== 0) continue;
|
||||
const ax = Math.abs(x);
|
||||
const ay = Math.abs(y);
|
||||
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
|
||||
if (!insetStripeColorIsChromatic(shadow[9])) continue;
|
||||
const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
|
||||
const line = lineOffset + content.slice(0, selectorStart).split('\n').length;
|
||||
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Astro/Vue/Svelte <style> blocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractStyleBlocks(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext !== '.vue' && ext !== '.svelte') return [];
|
||||
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
@@ -477,8 +578,9 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
if (cssLike.has(ext)) findings.push(...scanInsetStripeCss(content, filePath));
|
||||
|
||||
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
||||
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
|
||||
const styleBlocks = profile
|
||||
? profileStep(profile, {
|
||||
engine: 'regex',
|
||||
@@ -493,6 +595,7 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
@@ -510,6 +613,7 @@ function detectText(content, filePath, options = {}) {
|
||||
profile,
|
||||
phase: 'css-in-js',
|
||||
}));
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
|
||||
}
|
||||
|
||||
if (options?.designSystem) {
|
||||
|
||||
@@ -346,12 +346,16 @@ export function normalizeIgnoreValueEntries(entries) {
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
// Key order is rule, value, files, createdAt, reason and must stay that way:
|
||||
// normalizing runs on every write, so emitting a different order than the one
|
||||
// already on disk rewrites every untouched entry and churns the diff. Keep in
|
||||
// step with normalizeIgnoreValueEntries in skill/scripts/hook-lib.mjs.
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"smoke:hooks": "node scripts/smoke-provider-hooks.mjs",
|
||||
"bench:detector": "node scripts/benchmark-detector.mjs",
|
||||
"bench:detector:browser": "node scripts/benchmark-detector.mjs --browser",
|
||||
"bench:live": "node scripts/benchmark-live.mjs",
|
||||
"audit": "bun audit --audit-level=moderate",
|
||||
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
|
||||
"postpack": "cp README.repo.md README.md && rm README.repo.md",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { parseArgs, positiveIntFlag } from './lib/cli-args.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const iterations = positiveIntFlag(args.iterations, 5);
|
||||
const fixture = args.fixture ? String(args.fixture) : 'vite8-react-plain';
|
||||
const metricsFile = path.join(os.tmpdir(), 'impeccable-live-control-' + process.pid + '.jsonl');
|
||||
|
||||
try {
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
execFileSync('bun', ['run', 'test:live-e2e'], {
|
||||
cwd: root,
|
||||
stdio: 'ignore',
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
...process.env,
|
||||
IMPECCABLE_E2E_ONLY: fixture,
|
||||
IMPECCABLE_E2E_SCENARIOS: 'progressive',
|
||||
IMPECCABLE_E2E_METRICS_FILE: metricsFile,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rows = readMetrics(metricsFile);
|
||||
console.log(JSON.stringify({
|
||||
fixture,
|
||||
iterations: rows.length,
|
||||
measuredAt: new Date().toISOString(),
|
||||
acceptToPicking: summarize(rows.map((row) => row.acceptToPickingMs)),
|
||||
nextGoToPickup: summarize(rows.map((row) => row.nextGoToPickupMs)),
|
||||
samples: rows,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
try { fs.unlinkSync(metricsFile); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the metrics the e2e run appended. Fail loudly rather than reporting a
|
||||
* summary of nothing: an absent file means the run never produced a sample, and
|
||||
* an ENOENT stack or a `{"medianMs": null}` report both read as "measured" when
|
||||
* nothing was measured at all.
|
||||
*/
|
||||
function readMetrics(file) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, 'utf-8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
throw new Error(`no metrics were recorded at ${file}. Did the e2e run emit IMPECCABLE_E2E_METRICS_FILE rows?`);
|
||||
}
|
||||
const rows = raw.trim().split('\n').filter(Boolean).map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw new Error(`metrics line ${index + 1} is not valid JSON: ${error.message}`);
|
||||
}
|
||||
});
|
||||
if (rows.length === 0) throw new Error(`metrics file ${file} is empty; nothing to summarize`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function summarize(values) {
|
||||
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
// Distinguish "every sample was missing this metric" from a real measurement.
|
||||
// percentile() on an empty array reads sorted[-1] and yields NaN, which
|
||||
// JSON.stringify turns into null and silently passes for a result.
|
||||
if (sorted.length === 0) return { samples: 0, medianMs: null, p95Ms: null, minMs: null, maxMs: null };
|
||||
return {
|
||||
samples: sorted.length,
|
||||
medianMs: percentile(sorted, 0.5),
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted.at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(sorted, p) {
|
||||
const index = (sorted.length - 1) * p;
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
return Math.round((sorted[lower] * (1 - (index - lower)) + sorted[upper] * (index - lower)) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { parseArgs, positiveIntFlag } from './lib/cli-args.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const liveScript = path.join(root, 'skill/scripts/live.mjs');
|
||||
const serverScript = path.join(root, 'skill/scripts/live-server.mjs');
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const iterations = positiveIntFlag(args.iterations, 10);
|
||||
const fixture = args.fixture ? String(args.fixture) : 'vite8-react-plain';
|
||||
const fixtureDir = path.join(root, 'tests/framework-fixtures', fixture, 'files');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-live-init-'));
|
||||
|
||||
try {
|
||||
fs.cpSync(fixtureDir, tmp, { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'PRODUCT.md'), '# Product\n\nA realistic Live initialization benchmark fixture.\n');
|
||||
fs.writeFileSync(path.join(tmp, 'DESIGN.md'), '# Design\n\nUse the fixture\'s existing type, color, and component system.\n');
|
||||
fs.mkdirSync(path.join(tmp, '.impeccable/live'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, '.impeccable/live/config.json'), JSON.stringify({
|
||||
files: ['index.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
cspChecked: true,
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const cold = [];
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
stop();
|
||||
cold.push(runLive());
|
||||
}
|
||||
|
||||
stop();
|
||||
runLive();
|
||||
const warm = [];
|
||||
for (let i = 0; i < iterations; i += 1) warm.push(runLive());
|
||||
|
||||
console.log(JSON.stringify({
|
||||
fixture,
|
||||
iterations,
|
||||
measuredAt: new Date().toISOString(),
|
||||
cold: summarize(cold),
|
||||
warm: summarize(warm),
|
||||
samples: { cold, warm },
|
||||
}, null, 2));
|
||||
} finally {
|
||||
stop();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runLive() {
|
||||
const start = performance.now();
|
||||
const stdout = execFileSync(process.execPath, [liveScript], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15_000,
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
const result = JSON.parse(stdout);
|
||||
if (!result.ok) throw new Error('live init failed: ' + stdout);
|
||||
return round(elapsed);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
try {
|
||||
execFileSync(process.execPath, [serverScript, 'stop'], {
|
||||
cwd: tmp,
|
||||
stdio: 'ignore',
|
||||
timeout: 5_000,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return {
|
||||
medianMs: percentile(sorted, 0.5),
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted.at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(sorted, value) {
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const index = (sorted.length - 1) * value;
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
const weight = index - lower;
|
||||
return round(sorted[lower] * (1 - weight) + sorted[upper] * weight);
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createFakeAgent } from '../tests/live-e2e/agent.mjs';
|
||||
import { createLlmAgent, resolveLlmAgentConfig } from '../tests/live-e2e/agents/llm-agent.mjs';
|
||||
import { bootFixtureSession, FIXTURES_DIR } from '../tests/live-e2e/session.mjs';
|
||||
import {
|
||||
clickDiscard,
|
||||
clickGo,
|
||||
drawAnnotationPinAndStroke,
|
||||
pickElement,
|
||||
waitForCycling,
|
||||
waitForHandshake,
|
||||
} from '../tests/live-e2e/ui.mjs';
|
||||
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum } from './lib/cli-args.mjs';
|
||||
import {
|
||||
buildInteractionRun,
|
||||
assembleSplitProgressiveOutput,
|
||||
createBenchmarkReport,
|
||||
createTraceRecorder,
|
||||
mergeBenchmarkReports,
|
||||
} from './lib/live-benchmark.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const fixtureName = String(args.fixture || 'vite8-react-plain');
|
||||
const iterations = positiveIntFlag(args.iterations, 5);
|
||||
const agentMode = resolveEnum(args.agent, ['fake', 'llm'], 'fake', '--agent');
|
||||
const scenario = resolveEnum(args.scenario, ['plain', 'annotated'], 'plain', '--scenario');
|
||||
const delivery = resolveEnum(args.delivery, ['atomic', 'progressive'], 'atomic', '--delivery');
|
||||
const simulatedTailMs = positiveIntFlag(args.simulatedTailMs, 0);
|
||||
const quiet = boolFlag(args.quiet);
|
||||
const outputPath = args.output ? resolve(ROOT, String(args.output)) : null;
|
||||
const fixture = JSON.parse(await readFile(join(FIXTURES_DIR, fixtureName, 'fixture.json'), 'utf-8'));
|
||||
if (!fixture.runtime) throw new Error(`fixture ${fixtureName} has no runtime configuration`);
|
||||
if (fixture.runtime.mode === 'insert') throw new Error('live benchmark currently measures replace-mode fixtures only');
|
||||
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch({ headless: !boolFlag(args.headed) });
|
||||
const recorder = createTraceRecorder();
|
||||
let session;
|
||||
|
||||
try {
|
||||
const agentInfo = await resolveAgent(agentMode, args);
|
||||
if (delivery === 'progressive' && agentMode === 'llm') {
|
||||
agentInfo.agent = createSplitProgressiveAgent(agentInfo.agent);
|
||||
}
|
||||
session = await bootFixtureSession({
|
||||
name: fixtureName,
|
||||
fixture,
|
||||
browser,
|
||||
agent: agentInfo.agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
trace: recorder.trace,
|
||||
progressive: delivery === 'progressive',
|
||||
progressiveDelayMs: delivery === 'progressive' ? simulatedTailMs : 0,
|
||||
atomicDelayMs: delivery === 'atomic' ? simulatedTailMs : 0,
|
||||
log: quiet ? () => {} : (message) => process.stderr.write(`[live-bench] ${message}\n`),
|
||||
});
|
||||
|
||||
recorder.mark('setup.handshake.start');
|
||||
session.page.on('request', (request) => {
|
||||
if (!request.url().endsWith('/events') || request.method() !== 'POST') return;
|
||||
let payload;
|
||||
try { payload = request.postDataJSON(); } catch { return; }
|
||||
if (payload?.type === 'generate' && payload.id) {
|
||||
recorder.mark('browser.generate_post', {
|
||||
id: payload.id,
|
||||
hasScreenshotPath: typeof payload.screenshotPath === 'string' && payload.screenshotPath.length > 0,
|
||||
commentCount: Array.isArray(payload.comments) ? payload.comments.length : 0,
|
||||
strokeCount: Array.isArray(payload.strokes) ? payload.strokes.length : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
await waitForHandshake(session.page);
|
||||
recorder.mark('setup.handshake.end');
|
||||
await installBrowserTimingProbe(session.page);
|
||||
|
||||
const runs = [];
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
for (let iteration = 1; iteration <= iterations; iteration += 1) {
|
||||
await pickElement(session.page, pickSelector, { resetPickMode: iteration > 1 });
|
||||
if (scenario === 'annotated') {
|
||||
await drawAnnotationPinAndStroke(session.page, { comment: 'Benchmark annotation' });
|
||||
}
|
||||
await resetBrowserTimingProbe(session.page, iteration);
|
||||
|
||||
const goStarted = recorder.mark('ui.go.start', { iteration, scenario });
|
||||
const firstVariant = waitForFirstVariant(session.page).then(() => {
|
||||
recorder.mark('browser.first_variant', { iteration, scenario });
|
||||
});
|
||||
|
||||
await clickGo(session.page);
|
||||
recorder.mark('ui.generating_visible', { iteration, scenario });
|
||||
await firstVariant;
|
||||
await waitForCycling(session.page, 3, { timeout: agentMode === 'llm' ? 150_000 : 30_000 });
|
||||
recorder.mark('browser.all_variants', { iteration, scenario });
|
||||
const browserTiming = await readBrowserTimingProbe(session.page);
|
||||
|
||||
const run = buildInteractionRun(recorder.events, {
|
||||
iteration,
|
||||
scenario,
|
||||
goStartedAt: goStarted.at,
|
||||
browserTiming,
|
||||
});
|
||||
assertScenarioEvidence(run, scenario);
|
||||
runs.push(run);
|
||||
|
||||
if (!quiet) process.stderr.write(formatRun(runs.at(-1)) + '\n');
|
||||
await clickDiscard(session.page);
|
||||
await waitForReset(session.page);
|
||||
}
|
||||
|
||||
const report = createBenchmarkReport({
|
||||
fixture: fixtureName,
|
||||
agent: agentMode,
|
||||
provider: agentInfo.provider,
|
||||
model: agentInfo.model,
|
||||
scenario,
|
||||
runs,
|
||||
events: recorder.events,
|
||||
harnessProbe: args.harnessProbe || null,
|
||||
delivery,
|
||||
promptMode: agentInfo.promptMode,
|
||||
simulation: simulatedTailMs > 0 ? { remainingGenerationMs: simulatedTailMs } : null,
|
||||
});
|
||||
|
||||
let output = report;
|
||||
if (outputPath && args.append) {
|
||||
try {
|
||||
const existing = JSON.parse(await readFile(outputPath, 'utf-8'));
|
||||
const previousReports = Array.isArray(existing.reports) ? existing.reports : [existing];
|
||||
output = mergeBenchmarkReports([...previousReports, report]);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(output, null, 2) + '\n';
|
||||
if (outputPath) {
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, json, 'utf-8');
|
||||
process.stderr.write(`[live-bench] wrote ${outputPath}\n`);
|
||||
}
|
||||
process.stdout.write(json);
|
||||
} finally {
|
||||
if (session) await session.teardown();
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
async function resolveAgent(mode, options) {
|
||||
if (mode === 'fake') return { agent: createFakeAgent(), provider: 'deterministic', model: null, promptMode: null };
|
||||
const config = resolveLlmAgentConfig({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
});
|
||||
const agent = await createLlmAgent({
|
||||
config,
|
||||
includeLiveSpec: false,
|
||||
log: (message) => process.stderr.write(`[live-bench:llm] ${message}\n`),
|
||||
});
|
||||
if (!agent) {
|
||||
throw new Error(`LLM benchmark provider=${config.provider} requires ${config.requiredEnv}. Pass it in the environment; .env files are not read implicitly.`);
|
||||
}
|
||||
return { agent, provider: config.provider, model: config.model, promptMode: 'synthetic-element-contract' };
|
||||
}
|
||||
|
||||
function createSplitProgressiveAgent(agent) {
|
||||
const firstBySession = new Map();
|
||||
return {
|
||||
...agent,
|
||||
async generateFirstVariant(event, context) {
|
||||
const first = await agent.generateVariants({
|
||||
...event,
|
||||
count: 1,
|
||||
progressive: { phase: 'first', totalCount: event.count },
|
||||
}, context);
|
||||
firstBySession.set(event.id, first);
|
||||
return first;
|
||||
},
|
||||
async generateRemainingVariants(event, context) {
|
||||
const first = firstBySession.get(event.id) || context.firstOutput;
|
||||
const remaining = await agent.generateVariants({
|
||||
...event,
|
||||
count: event.count,
|
||||
progressive: {
|
||||
phase: 'remaining',
|
||||
totalCount: event.count,
|
||||
firstVariant: first?.variants?.[0] || null,
|
||||
omitFirstVariantCss: true,
|
||||
},
|
||||
}, context);
|
||||
firstBySession.delete(event.id);
|
||||
return assembleSplitProgressiveOutput(first, remaining);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForFirstVariant(page) {
|
||||
const handle = await page.waitForFunction(() => {
|
||||
const wrappers = [...document.querySelectorAll('[data-impeccable-variant]')];
|
||||
return wrappers.some((element) => element.getAttribute('data-impeccable-variant') !== 'original');
|
||||
}, undefined, { timeout: 150_000 });
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async function waitForReset(page) {
|
||||
await page.waitForFunction(() => !document.querySelector('[data-impeccable-variants]'), undefined, { timeout: 30_000 });
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
async function installBrowserTimingProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
const state = { iteration: 0, goAt: null, generateAt: null };
|
||||
window.__IMPECCABLE_LIVE_BENCH_TIMING__ = state;
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
root.addEventListener('click', (event) => {
|
||||
const button = event.composedPath().find((node) =>
|
||||
node?.getAttribute?.('aria-label') === 'Generate variants'
|
||||
);
|
||||
if (button) state.goAt = performance.now();
|
||||
}, true);
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : input?.url;
|
||||
if (String(url || '').endsWith('/events') && init?.method === 'POST') {
|
||||
const payload = typeof init.body === 'string' ? JSON.parse(init.body) : null;
|
||||
if (payload?.type === 'generate') state.generateAt = performance.now();
|
||||
}
|
||||
} catch { /* measurement must never affect Live */ }
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resetBrowserTimingProbe(page, iteration) {
|
||||
await page.evaluate((nextIteration) => {
|
||||
const state = window.__IMPECCABLE_LIVE_BENCH_TIMING__;
|
||||
if (!state) return;
|
||||
state.iteration = nextIteration;
|
||||
state.goAt = null;
|
||||
state.generateAt = null;
|
||||
}, iteration);
|
||||
}
|
||||
|
||||
async function readBrowserTimingProbe(page) {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__IMPECCABLE_LIVE_BENCH_TIMING__;
|
||||
return state ? { ...state } : null;
|
||||
});
|
||||
}
|
||||
|
||||
function assertScenarioEvidence(run, currentScenario) {
|
||||
const evidence = run.annotationEvidence;
|
||||
if (currentScenario === 'annotated') {
|
||||
if (!evidence?.screenshotPath || evidence.comments < 1 || evidence.strokes < 1) {
|
||||
throw new Error(`iteration ${run.iteration}: annotated generate payload lost screenshot/comments/strokes`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (evidence?.screenshotPath) {
|
||||
throw new Error(`iteration ${run.iteration}: plain generate payload unexpectedly included screenshotPath`);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapTargetFromPickedElement(event) {
|
||||
const element = event.element || {};
|
||||
return {
|
||||
elementId: element.id || undefined,
|
||||
classes: Array.isArray(element.classes) ? element.classes.join(',') : undefined,
|
||||
tag: element.tagName ? String(element.tagName).toLowerCase() : undefined,
|
||||
text: element.textContent ? String(element.textContent).trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRun(run) {
|
||||
return `[live-bench] run ${run.iteration}: first=${run.goToFirstVariantMs}ms all=${run.goToAllVariantsMs}ms generation=${run.generationMs}ms overhead=${run.impeccableOverheadMs}ms`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { parseArgs } from './lib/cli-args.mjs';
|
||||
import { compareModelBackedReports } from './lib/live-benchmark.mjs';
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.atomic || !args.progressive) {
|
||||
throw new Error('usage: node scripts/compare-live-benchmarks.mjs --atomic=<report.json> --progressive=<report.json>');
|
||||
}
|
||||
|
||||
const [atomic, progressive] = await Promise.all([
|
||||
readReport(args.atomic, 'atomic'),
|
||||
readReport(args.progressive, 'progressive'),
|
||||
]);
|
||||
const comparison = compareModelBackedReports(atomic, progressive, {
|
||||
medianTarget: ratioArg(args.medianTarget, 0.35),
|
||||
p95Target: ratioArg(args.p95Target, 0.25),
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(comparison, null, 2) + '\n');
|
||||
if (!comparison.passed) process.exitCode = 1;
|
||||
|
||||
async function readReport(file, delivery) {
|
||||
const value = JSON.parse(await readFile(resolve(String(file)), 'utf-8'));
|
||||
const reports = Array.isArray(value?.reports) ? value.reports : [value];
|
||||
const report = reports.find((item) => item?.benchmark?.delivery === delivery);
|
||||
if (!report) throw new Error(`${file} does not contain a ${delivery} benchmark report`);
|
||||
return report;
|
||||
}
|
||||
|
||||
function ratioArg(value, fallback) {
|
||||
if (value == null || value === true) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed >= 1) throw new Error(`invalid threshold ratio: ${value}`);
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* One argv parser for the Live benchmark / judging scripts.
|
||||
*
|
||||
* These scripts had four subtly different hand-rolled parsers, and the gaps
|
||||
* failed silently rather than loudly: a parser without the `argv[i + 1]`
|
||||
* lookahead turned `--iterations 20` into `iterations: true` and benchmarked
|
||||
* the default 5 runs; a parser without kebab→camel mapping turned
|
||||
* `--median-target=0.4` into a key nothing read, so the comparison ran against
|
||||
* the default threshold. Both produce a clean-looking report of the wrong thing.
|
||||
*
|
||||
* Supported forms, per flag:
|
||||
* --flag → true
|
||||
* --flag=value → 'value'
|
||||
* --flag value → 'value' (unless `value` itself starts with `--`)
|
||||
*
|
||||
* Keys are camel-cased, so `--simulated-tail-ms` and `--simulatedTailMs` both
|
||||
* land on `simulatedTailMs`.
|
||||
*/
|
||||
export function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const body = arg.slice(2);
|
||||
if (!body) continue;
|
||||
const equals = body.indexOf('=');
|
||||
if (equals !== -1) {
|
||||
out[toCamel(body.slice(0, equals))] = body.slice(equals + 1);
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
out[toCamel(body)] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
out[toCamel(body)] = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toCamel(value) {
|
||||
return String(value).replace(/-([a-z0-9])/gi, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a boolean flag. `--headed` and `--headed=true` must mean the same thing;
|
||||
* comparing the raw value against `true` silently ignores the second form.
|
||||
*/
|
||||
export function boolFlag(value, fallback = false) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['', 'true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a positive integer flag, falling back when absent. Throws on a value
|
||||
* that was clearly meant as a number but isn't one, so `--iterations abc`
|
||||
* fails instead of quietly benchmarking the default.
|
||||
*/
|
||||
export function positiveIntFlag(value, fallback) {
|
||||
if (value === undefined || value === true) return fallback;
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== String(value).trim()) {
|
||||
throw new Error(`expected a positive integer, got: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a flag that must be one of a fixed set.
|
||||
*
|
||||
* A silent `x === 'known' ? 'known' : fallback` is the trap this replaces: the
|
||||
* private evals Live runner passes `--agent=codex`, which fell through to the
|
||||
* canned fake agent and produced a clean-looking report of a deterministic stub
|
||||
* labelled as a real harness run. An unrecognized value is a mistake, not a
|
||||
* request for the default.
|
||||
*/
|
||||
export function resolveEnum(value, allowed, fallback, flagName) {
|
||||
if (value === undefined || value === true) return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (allowed.includes(normalized)) return normalized;
|
||||
throw new Error(`${flagName} must be one of ${allowed.join(', ')}; got: ${value}`);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const METRIC_KEYS = [
|
||||
'browserPreparationMs',
|
||||
'browserDispatchMs',
|
||||
'automationClickMs',
|
||||
'serverPickupMs',
|
||||
'goToAgentMs',
|
||||
'serverPreflightMs',
|
||||
'scaffoldMs',
|
||||
'generationToFirstMs',
|
||||
'generationMs',
|
||||
'firstVariantWriteMs',
|
||||
'writeMs',
|
||||
'writeToFirstVariantMs',
|
||||
'replyMs',
|
||||
'goToFirstVariantMs',
|
||||
'goToAllVariantsMs',
|
||||
'deliveryGapMs',
|
||||
'impeccableOverheadMs',
|
||||
];
|
||||
|
||||
export function createTraceRecorder(now = () => performance.now()) {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
trace(name, data = {}) {
|
||||
events.push({ name, at: now(), ...data });
|
||||
},
|
||||
mark(name, data = {}) {
|
||||
const event = { name, at: now(), ...data };
|
||||
events.push(event);
|
||||
return event;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function durationBetween(events, startName, endName, predicate = () => true) {
|
||||
const start = events.find((event) => event.name === startName && predicate(event));
|
||||
const end = events.find((event) => event.name === endName && predicate(event) && (!start || event.at >= start.at));
|
||||
if (!start || !end) return null;
|
||||
return roundMs(Math.max(0, end.at - start.at));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the two model calls used by the Live benchmark's progressive path.
|
||||
* The first checkpoint is already visible in the browser, so both its markup
|
||||
* and CSS are immutable. The tail call may supply deferred params for variant
|
||||
* 1, but its CSS must contain only independently-scoped rules for variants 2+.
|
||||
*/
|
||||
export function assembleSplitProgressiveOutput(first, remaining) {
|
||||
const firstVariant = first?.variants?.[0];
|
||||
if (!firstVariant) throw new Error('progressive assembly requires a first variant');
|
||||
if (!Array.isArray(remaining?.variants) || remaining.variants.length < 1) {
|
||||
throw new Error('progressive assembly requires a complete remaining variant set');
|
||||
}
|
||||
|
||||
const firstCss = String(first.scopedCss || '');
|
||||
const laterCss = String(remaining.scopedCss || '');
|
||||
assertLaterVariantCss(laterCss);
|
||||
|
||||
return {
|
||||
scopedCss: firstCss && laterCss ? `${firstCss}\n${laterCss}` : firstCss || laterCss,
|
||||
variants: [
|
||||
{
|
||||
...firstVariant,
|
||||
params: Array.isArray(remaining.variants[0]?.params)
|
||||
? remaining.variants[0].params
|
||||
: [],
|
||||
},
|
||||
...remaining.variants.slice(1),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInteractionRun(events, { iteration, scenario, goStartedAt, browserTiming = null }) {
|
||||
const received = events.find((event) =>
|
||||
event.name === 'agent.event.received'
|
||||
&& event.type === 'generate'
|
||||
&& event.at >= goStartedAt
|
||||
);
|
||||
if (!received?.id) throw new Error(`iteration ${iteration}: no generate event was traced`);
|
||||
|
||||
const id = received.id;
|
||||
const forId = (event) => event.id === id;
|
||||
const eventPost = events.find((event) => event.name === 'browser.generate_post' && forId(event));
|
||||
const mark = (name) => events.find((event) => event.name === name && event.iteration === iteration);
|
||||
const first = mark('browser.first_variant');
|
||||
const all = mark('browser.all_variants');
|
||||
const writeEnd = events.find((event) => event.name === 'agent.write.end' && forId(event));
|
||||
const firstWriteEnd = events.find((event) => event.name === 'agent.first_variant.write.end' && forId(event));
|
||||
const reusedScaffold = events.find((event) => event.name === 'agent.scaffold.reused' && forId(event));
|
||||
const generationMs = durationBetween(events, 'agent.generate.start', 'agent.generate.end', forId);
|
||||
const generationToFirstMs = durationBetween(events, 'agent.generate.start', 'agent.generate.first_ready', forId);
|
||||
const browserPreparationMs = eventPost ? roundMs(eventPost.at - goStartedAt) : null;
|
||||
const browserDispatchMs = Number.isFinite(browserTiming?.goAt) && Number.isFinite(browserTiming?.generateAt)
|
||||
? roundMs(Math.max(0, browserTiming.generateAt - browserTiming.goAt))
|
||||
: null;
|
||||
const interactionStartedAt = eventPost && browserDispatchMs != null
|
||||
? eventPost.at - browserDispatchMs
|
||||
: goStartedAt;
|
||||
const measuredGoToFirstVariantMs = first ? roundMs(first.at - interactionStartedAt) : null;
|
||||
const measuredGoToAllVariantsMs = all ? roundMs(all.at - interactionStartedAt) : null;
|
||||
|
||||
return {
|
||||
iteration,
|
||||
scenario,
|
||||
eventId: id,
|
||||
annotationEvidence: {
|
||||
screenshotPath: eventPost?.hasScreenshotPath === true,
|
||||
comments: Number(eventPost?.commentCount || 0),
|
||||
strokes: Number(eventPost?.strokeCount || 0),
|
||||
},
|
||||
browserPreparationMs,
|
||||
browserDispatchMs,
|
||||
automationClickMs: browserPreparationMs == null || browserDispatchMs == null
|
||||
? null
|
||||
: roundMs(Math.max(0, browserPreparationMs - browserDispatchMs)),
|
||||
serverPickupMs: eventPost ? roundMs(Math.max(0, received.at - eventPost.at)) : null,
|
||||
goToAgentMs: roundMs(received.at - interactionStartedAt),
|
||||
serverPreflightMs: Number.isFinite(reusedScaffold?.durationMs) ? roundMs(reusedScaffold.durationMs) : null,
|
||||
scaffoldMs: durationBetween(events, 'agent.scaffold.start', 'agent.scaffold.end', forId),
|
||||
generationToFirstMs,
|
||||
generationMs,
|
||||
firstVariantWriteMs: durationBetween(events, 'agent.first_variant.write.start', 'agent.first_variant.write.end', forId),
|
||||
writeMs: durationBetween(events, 'agent.write.start', 'agent.write.end', forId),
|
||||
writeToFirstVariantMs: first && (firstWriteEnd || writeEnd)
|
||||
? roundMs(Math.max(0, first.at - (firstWriteEnd || writeEnd).at))
|
||||
: null,
|
||||
replyMs: durationBetween(events, 'agent.reply.start', 'agent.reply.end', forId),
|
||||
goToFirstVariantMs: measuredGoToFirstVariantMs,
|
||||
goToAllVariantsMs: measuredGoToAllVariantsMs,
|
||||
deliveryGapMs: first && all ? roundMs(Math.max(0, all.at - first.at)) : null,
|
||||
impeccableOverheadMs: measuredGoToFirstVariantMs == null || generationToFirstMs == null
|
||||
? null
|
||||
: roundMs(Math.max(0, measuredGoToFirstVariantMs - generationToFirstMs)),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeRuns(runs) {
|
||||
const metrics = {};
|
||||
for (const key of METRIC_KEYS) {
|
||||
const values = runs.map((run) => run[key]).filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (values.length === 0) continue;
|
||||
metrics[key] = {
|
||||
median: roundMs(percentile(values, 0.5)),
|
||||
p95: roundMs(percentile(values, 0.95)),
|
||||
min: roundMs(values[0]),
|
||||
max: roundMs(values[values.length - 1]),
|
||||
};
|
||||
}
|
||||
return { count: runs.length, metrics };
|
||||
}
|
||||
|
||||
export function summarizeSetup(events) {
|
||||
const stages = [
|
||||
['dependencies', 'setup.install.start', 'setup.install.end'],
|
||||
['liveServer', 'setup.live_server.start', 'setup.live_server.end'],
|
||||
['injection', 'setup.inject.start', 'setup.inject.end'],
|
||||
['devServer', 'setup.dev_server.start', 'setup.dev_server.end'],
|
||||
['pageLoad', 'setup.page_load.start', 'setup.page_load.end'],
|
||||
['handshake', 'setup.handshake.start', 'setup.handshake.end'],
|
||||
];
|
||||
return Object.fromEntries(stages.map(([key, start, end]) => [key, durationBetween(events, start, end)]));
|
||||
}
|
||||
|
||||
export function createBenchmarkReport({
|
||||
fixture,
|
||||
agent,
|
||||
provider,
|
||||
model,
|
||||
scenario,
|
||||
runs,
|
||||
events,
|
||||
harnessProbe = null,
|
||||
delivery = 'atomic',
|
||||
promptMode = null,
|
||||
simulation = null,
|
||||
generatedAt = new Date().toISOString(),
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
benchmark: {
|
||||
fixture,
|
||||
agent,
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
scenario,
|
||||
variants: 3,
|
||||
delivery,
|
||||
promptMode,
|
||||
simulation,
|
||||
},
|
||||
setup: summarizeSetup(events),
|
||||
summary: summarizeRuns(runs),
|
||||
runs,
|
||||
harnessProbe,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeBenchmarkReports(reports, generatedAt = new Date().toISOString()) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
reports,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareModelBackedReports(atomic, progressive, {
|
||||
medianTarget = 0.35,
|
||||
p95Target = 0.25,
|
||||
minimumRuns = 3,
|
||||
} = {}) {
|
||||
assertComparableModelReport(atomic, 'atomic', minimumRuns);
|
||||
assertComparableModelReport(progressive, 'progressive', minimumRuns);
|
||||
|
||||
for (const key of ['fixture', 'provider', 'model', 'scenario', 'variants', 'promptMode']) {
|
||||
if (atomic.benchmark[key] !== progressive.benchmark[key]) {
|
||||
throw new Error(`benchmark mismatch for ${key}: atomic=${atomic.benchmark[key]} progressive=${progressive.benchmark[key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
const atomicFirst = requiredMetric(atomic, 'goToFirstVariantMs');
|
||||
const progressiveFirst = requiredMetric(progressive, 'goToFirstVariantMs');
|
||||
const medianImprovement = improvement(atomicFirst.median, progressiveFirst.median);
|
||||
const p95Improvement = improvement(atomicFirst.p95, progressiveFirst.p95);
|
||||
const allReady = {
|
||||
atomic: requiredMetric(atomic, 'goToAllVariantsMs'),
|
||||
progressive: requiredMetric(progressive, 'goToAllVariantsMs'),
|
||||
};
|
||||
const passed = medianImprovement >= medianTarget && p95Improvement >= p95Target;
|
||||
|
||||
return {
|
||||
passed,
|
||||
target: { medianImprovement, p95Improvement, medianTarget, p95Target },
|
||||
firstReviewable: { atomic: atomicFirst, progressive: progressiveFirst },
|
||||
allVariantsReady: allReady,
|
||||
benchmark: {
|
||||
fixture: atomic.benchmark.fixture,
|
||||
provider: atomic.benchmark.provider,
|
||||
model: atomic.benchmark.model,
|
||||
scenario: atomic.benchmark.scenario,
|
||||
runs: { atomic: atomic.summary.count, progressive: progressive.summary.count },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertComparableModelReport(report, delivery, minimumRuns) {
|
||||
if (!report?.benchmark || !report?.summary) throw new Error(`${delivery} benchmark report is missing metadata or summary`);
|
||||
if (report.benchmark.agent !== 'llm') throw new Error(`${delivery} benchmark must be model-backed (agent=llm)`);
|
||||
if (report.benchmark.delivery !== delivery) {
|
||||
throw new Error(`expected ${delivery} delivery report, got ${report.benchmark.delivery || 'unknown'}`);
|
||||
}
|
||||
if (report.benchmark.simulation) throw new Error(`${delivery} model benchmark must not contain simulated latency`);
|
||||
if (!report.benchmark.provider || !report.benchmark.model) throw new Error(`${delivery} benchmark is missing provider/model identity`);
|
||||
if (!Number.isInteger(report.summary.count) || report.summary.count < minimumRuns) {
|
||||
throw new Error(`${delivery} benchmark requires at least ${minimumRuns} runs`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredMetric(report, key) {
|
||||
const metric = report.summary.metrics?.[key];
|
||||
if (!Number.isFinite(metric?.median) || !Number.isFinite(metric?.p95)) {
|
||||
throw new Error(`${report.benchmark.delivery} benchmark is missing ${key} median/p95`);
|
||||
}
|
||||
return { median: metric.median, p95: metric.p95 };
|
||||
}
|
||||
|
||||
function improvement(baseline, candidate) {
|
||||
if (!(baseline > 0) || !Number.isFinite(candidate)) throw new Error('benchmark latency must be finite and baseline must be positive');
|
||||
return Number((1 - (candidate / baseline)).toFixed(4));
|
||||
}
|
||||
|
||||
function assertLaterVariantCss(css) {
|
||||
if (!css.trim()) return;
|
||||
for (const prelude of topLevelCssPreludes(css)) {
|
||||
const variants = [...prelude.matchAll(/\[data-impeccable-variant\s*=\s*(["'])(\d+)\1[^\]]*\]/g)]
|
||||
.map((match) => Number(match[2]));
|
||||
if (variants.includes(1)) {
|
||||
throw new Error('progressive tail CSS must not repeat or conflict with published variant 1 CSS');
|
||||
}
|
||||
if (variants.length === 0 || variants.some((variant) => variant < 2)) {
|
||||
throw new Error('progressive tail CSS must be attributable only to variants 2+');
|
||||
}
|
||||
if (new Set(variants).size !== 1) {
|
||||
throw new Error('each progressive tail CSS block must target exactly one later variant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function topLevelCssPreludes(css) {
|
||||
const preludes = [];
|
||||
let cursor = 0;
|
||||
while (cursor < css.length) {
|
||||
while (cursor < css.length && /\s/.test(css[cursor])) cursor += 1;
|
||||
if (cursor >= css.length) break;
|
||||
const start = cursor;
|
||||
const open = findCssToken(css, cursor, '{');
|
||||
if (open === -1) throw new Error('progressive tail CSS contains a rule without a block');
|
||||
const prelude = css.slice(start, open).trim();
|
||||
if (!prelude || prelude.includes(';')) {
|
||||
throw new Error('progressive tail CSS must contain scoped rule blocks only');
|
||||
}
|
||||
preludes.push(prelude);
|
||||
const close = findMatchingCssBrace(css, open);
|
||||
if (close === -1) throw new Error('progressive tail CSS has unbalanced braces');
|
||||
cursor = close + 1;
|
||||
}
|
||||
return preludes;
|
||||
}
|
||||
|
||||
function findCssToken(css, start, token) {
|
||||
let quote = null;
|
||||
let comment = false;
|
||||
for (let index = start; index < css.length; index += 1) {
|
||||
const char = css[index];
|
||||
const next = css[index + 1];
|
||||
if (comment) {
|
||||
if (char === '*' && next === '/') {
|
||||
comment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!quote && char === '/' && next === '*') {
|
||||
comment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === '\\') index += 1;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === token) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findMatchingCssBrace(css, open) {
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let comment = false;
|
||||
for (let index = open; index < css.length; index += 1) {
|
||||
const char = css[index];
|
||||
const next = css[index + 1];
|
||||
if (comment) {
|
||||
if (char === '*' && next === '/') {
|
||||
comment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!quote && char === '/' && next === '*') {
|
||||
comment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === '\\') index += 1;
|
||||
else if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function percentile(sortedValues, ratio) {
|
||||
if (sortedValues.length === 1) return sortedValues[0];
|
||||
const index = (sortedValues.length - 1) * ratio;
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
if (lower === upper) return sortedValues[lower];
|
||||
const weight = index - lower;
|
||||
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
|
||||
}
|
||||
|
||||
function roundMs(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
@@ -22,7 +22,10 @@ export const PROVIDERS = {
|
||||
},
|
||||
'claude-code': {
|
||||
provider: 'claude-code',
|
||||
providerTags: ['claude-code', 'claude'],
|
||||
// live-progressive: Live delivers variant 1 as soon as it validates instead of
|
||||
// one atomic edit. Claude Code polls in a background task, so the extra
|
||||
// publish calls do not stall its control lane.
|
||||
providerTags: ['claude-code', 'claude', 'live-progressive'],
|
||||
configDir: '.claude',
|
||||
displayName: 'Claude Code',
|
||||
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'],
|
||||
@@ -40,7 +43,7 @@ export const PROVIDERS = {
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
providerTags: ['codex'],
|
||||
providerTags: ['codex', 'live-progressive'],
|
||||
configDir: '.codex',
|
||||
displayName: 'Codex',
|
||||
frontmatterFields: [],
|
||||
@@ -54,7 +57,7 @@ export const PROVIDERS = {
|
||||
},
|
||||
agents: {
|
||||
provider: 'agents',
|
||||
providerTags: ['agents', 'codex'],
|
||||
providerTags: ['agents', 'codex', 'live-progressive'],
|
||||
configDir: '.agents',
|
||||
displayName: 'Codex Repo Skills',
|
||||
placeholderProvider: 'codex',
|
||||
|
||||
@@ -645,6 +645,11 @@ export const PROVIDER_BLOCK_TAGS = new Set([
|
||||
'rovo-dev',
|
||||
'trae',
|
||||
'trae-cn',
|
||||
// Capability tags. Not harness names: they mark instructions that belong to a
|
||||
// shared capability several harnesses opt into. Listing the harnesses instead
|
||||
// would mean duplicating the block body per provider tag, since a block takes
|
||||
// one tag. Opt a provider in by adding the tag to its providerTags.
|
||||
'live-progressive',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ export const SUITES = {
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/ci-test-plan.test.mjs',
|
||||
'tests/cli-args.test.mjs',
|
||||
'tests/context.test.mjs',
|
||||
'tests/context-signals.test.mjs',
|
||||
'tests/critique-storage.test.mjs',
|
||||
@@ -124,6 +125,7 @@ export const SUITES = {
|
||||
'tests/live-browser-regression.test.mjs',
|
||||
'tests/live-browser-session.test.mjs',
|
||||
'tests/live-browser-source.test.mjs',
|
||||
'tests/live-benchmark.test.mjs',
|
||||
'tests/live-commit-manual-edits.test.mjs',
|
||||
'tests/live-completion.test.mjs',
|
||||
'tests/live-copy-edit-agent.test.mjs',
|
||||
@@ -134,17 +136,22 @@ export const SUITES = {
|
||||
'tests/live-e2e-steer-agent.test.mjs',
|
||||
'tests/live-e2e/agent-insert.test.mjs',
|
||||
'tests/live-event-validation.test.mjs',
|
||||
'tests/live-generation-preflight.test.mjs',
|
||||
'tests/live-generation-publisher.test.mjs',
|
||||
'tests/live-inject.test.mjs',
|
||||
'tests/live-insert.test.mjs',
|
||||
'tests/live-insert-ui.test.mjs',
|
||||
'tests/live-manual-edits-buffer.test.mjs',
|
||||
'tests/live-poll.test.mjs',
|
||||
'tests/live-poll-lanes.test.mjs',
|
||||
'tests/live-poll-stream.test.mjs',
|
||||
'tests/live-recovery-commands.test.mjs',
|
||||
'tests/live-reference.test.mjs',
|
||||
'tests/live-server.test.mjs',
|
||||
'tests/live-session-store.test.mjs',
|
||||
'tests/live-source-lock.test.mjs',
|
||||
'tests/live-target-context.test.mjs',
|
||||
'tests/live-vue-component.test.mjs',
|
||||
'tests/live-wrap.test.mjs',
|
||||
'tests/live-wrap-buffer-aware.test.mjs',
|
||||
],
|
||||
|
||||
@@ -23,10 +23,11 @@ The first argument is the action. Defaults to `status`.
|
||||
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
|
||||
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
|
||||
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. Suppresses the rule across the whole project. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. Suppresses **every** rule for matching files. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
|
||||
## Flow
|
||||
@@ -51,7 +52,8 @@ Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
|
||||
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
@@ -73,7 +75,14 @@ Example whole-rule font exception:
|
||||
node {{scripts_path}}/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
|
||||
```
|
||||
|
||||
Example file-scoped exception:
|
||||
Example one-rule-in-one-file exception, for a file that is still worth reviewing
|
||||
for everything else:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/hook-admin.mjs ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site"
|
||||
```
|
||||
|
||||
Example whole-file exception, for a file that is out of scope entirely:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
|
||||
|
||||
+60
-18
@@ -14,21 +14,28 @@ Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node {{scripts_path}}/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free while you generate and publish in it. Do not block the shell.
|
||||
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
|
||||
- **Codex**: run the poll in the **foreground** (blocking shell; not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
|
||||
- **Codex**: run the default one-shot poll in a **yielded foreground exec session**. Do not suffix it with `&`, use `--stream`, or leave Live without an active foreground poll. Handle every event in the main task; after each handler/reply, restart the foreground poll.
|
||||
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
|
||||
|
||||
Generation delivery policy:
|
||||
- **Default (Cursor and other harnesses):** keep the established atomic single-edit delivery. Do not switch a harness to progressive until its poll loop is known not to block on the extra publish calls. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
|
||||
|
||||
<live-progressive>
|
||||
- **Progressive delivery (Codex, Claude Code):** deliver progressively through `live-publish.mjs`, never by editing project source directly. Publish variant 1 as soon as it is complete, then publish each additional validated variant (or the largest ready prefix) without waiting for later siblings. Attach parameter CSS/manifests only with the complete set. The browser makes every arrived variant immediately reviewable and acceptable; Accept/Discard durably cancel unfinished revisions. The user reviews the first direction while the rest are still being written, so time-to-first-variant is what matters, not total time.
|
||||
</live-progressive>
|
||||
|
||||
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
|
||||
|
||||
## Start
|
||||
@@ -96,14 +103,14 @@ Server restart rule: start `live-server.mjs` again, then poll. Startup requeues
|
||||
|
||||
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
|
||||
|
||||
Speed matters; the user is watching a spinner. Minimize tool calls by using the wrap/insert helper and writing all variants in a single edit.
|
||||
Speed matters; the user is watching the selected element. Reuse server preflight metadata when available, minimize discovery calls, and follow the harness-specific delivery policy above.
|
||||
|
||||
### Insert mode branch
|
||||
|
||||
When `event.mode === "insert"`:
|
||||
|
||||
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
|
||||
2. Run the insert helper instead of wrap:
|
||||
2. If `event.scaffold` is present, use it as the insert-helper result and do **not** run the helper again. Otherwise run the insert helper instead of wrap:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
|
||||
@@ -113,7 +120,7 @@ node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --positi
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -138,6 +145,8 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
```
|
||||
@@ -157,7 +166,9 @@ Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssS
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
For Nuxt/Vue targets, `live-wrap.mjs` returns `previewMode: "vue-component"` with `file` pointing at an app-local generated manifest under `<appDir>/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at real Vue SFC variants, and `sourceFile` pointing at the untouched `.vue` route. Write `v1.vue`, `v2.vue`, … with one root inside `<template>` and variant CSS in `<style scoped>`; keep dynamic text on the `propContract` bindings as `{{ propName }}`. Do **not** rewrite `sourceFile` during generation: Nuxt/Vite compiles and mounts these dev-only modules without invalidating the route. Accept is the only route write and inlines the selected template/CSS under the source lock; Discard deletes the generated session.
|
||||
|
||||
**Params on component-preview paths go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, and both Svelte/Vue previews mount without an HTML variant wrapper. Declare params in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -295,11 +306,40 @@ In **departure mode**, the prompt narrows the lanes you draw from, not the famil
|
||||
|
||||
When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X, the anti-references ban X), the anti-references win; they describe the brand's standing position, the prompt is one moment.
|
||||
|
||||
### 6. Write all variants in a single edit
|
||||
### 6. Deliver variants
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and keeps each delivered state internally complete (no FOUC).
|
||||
|
||||
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
|
||||
|
||||
<live-progressive>
|
||||
**Transactional progressive delivery (Codex, Claude Code):**
|
||||
|
||||
1. Plan all directions and name their parameter axes first so the trio remains coherent.
|
||||
2. Prepare revision 1 from the scaffolded source:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-publish.mjs --prepare --id EVENT_ID --file SOURCE_FILE
|
||||
```
|
||||
|
||||
The JSON result contains `artifactFile`, `epoch`, and `expectedSourceHash`. For the normal source-wrapper path, `artifactFile` is a staging copy of the already-wrapped source; edit **only `artifactFile`** at `insertLine`: write variant 1 and only the CSS it needs. Do not attach `data-impeccable-params` yet. Publishing replaces the wrapped source atomically, and `expectedSourceHash` is the fence that makes it safe: if the file moved under you the publish is rejected rather than clobbering it. The wrapper itself is already in your source from the scaffold, so preview markers are visible there until Accept or Discard removes them; do not hand-edit the file while a publish may be in flight.
|
||||
|
||||
For `previewMode: "svelte-component"` or `"vue-component"`, `artifactFile` is an isolated manifest and `componentDir` is its isolated component directory. Write `v1.svelte` or `v1.vue` under the returned `componentDir`, set the artifact manifest's `arrivedVariants` to `1`, and leave `params.json` absent. Keep `--file` pointed at the original live manifest on publish; the publisher fences against `targetSourceFile`, promotes the component, then commits the live manifest last. Never edit the live `componentDir` directly.
|
||||
3. Publish revision 1 with the exact fence values returned by `--prepare`:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-publish.mjs --id EVENT_ID --epoch EPOCH \
|
||||
--file SOURCE_FILE --artifact ARTIFACT_FILE --expected-source-hash SOURCE_HASH \
|
||||
--arrived 1 --expected EVENT_COUNT
|
||||
```
|
||||
|
||||
`{ok:false,error:"stale_generation_epoch"}` means the user already accepted or discarded. Stop immediately, do not touch source, and post the generation reply as canceled/error.
|
||||
4. Continue variants 2 through `EVENT_COUNT` from the stored plan. Whenever another direction validates, run `--prepare` again so the revision starts from the immutable published prefix, add the largest ready prefix without changing any published variant or default appearance, and publish it immediately. Attach parameter CSS/manifests only when the complete set is ready, using `--kind params`. On component-preview paths, preserve every already-published `vN.svelte` / `vN.vue` byte-for-byte; publication rejects a revision that silently changes a variant the user may already be reviewing.
|
||||
5. A params-only pass is recovery-only: use it when durable state says every variant arrived but `paramsPublished` is still false after an interrupted publication.
|
||||
6. Verify the published preview parses, then `--reply done`. A late reply is diagnostic only after Accept/Discard and cannot move the durable session backward.
|
||||
</live-progressive>
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
@@ -323,7 +363,7 @@ Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporar
|
||||
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
The browser's MutationObserver accepts either delivery shape. On the transactional progressive path it shows arrived variants and pending dots immediately; Accept and Discard are available as soon as one variant exists. Accepting an arrived variant fences the worker before the browser releases the picker, so later publications are rejected.
|
||||
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
@@ -365,7 +405,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
|
||||
|
||||
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On `svelte-component` and `vue-component` paths, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path.
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
@@ -466,15 +506,19 @@ Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already
|
||||
- The accept event includes `pageUrl`; the poll script must forward it to `live-accept.mjs --page-url PAGE_URL` so accept-time cleanup only scrubs staged copy edits for the current page.
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: post-accept cleanup is required, but it must not stall Codex's control lane. See "Required after accept (carbonize)" below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and stderr banner all point at this required follow-up; none are decorative.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false, mode: "error"`: the operation genuinely failed. **Do not hand-edit the file**; the source was not touched and editing it yourself would either double-apply or race whoever holds it.
|
||||
- `error: "source_locked"`: a generation publish holds the file. Run the same `live-accept.mjs` command again; it is idempotent and will succeed once the publisher releases. Do not poll past it.
|
||||
- `error: "accept_receipt_conflict"`: this session already resolved as `priorOperation` (on `priorVariantId` for an accept), so the request contradicts durable truth. Do not edit. Run `live-status.mjs` and tell the user what the session actually resolved to.
|
||||
- anything else: report the error briefly and run `live-status.mjs` before continuing.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
### Required after accept (carbonize)
|
||||
|
||||
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
|
||||
|
||||
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
|
||||
Do these five steps synchronously before the next poll. The source lock, generation epoch, and expected-source hash remain the final safety gates against a generator finishing concurrently with Accept.
|
||||
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
|
||||
@@ -482,9 +526,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. Poll again only after that verification.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||
* node hook-admin.mjs ignore-value <rule> "*" # refused: scope it or use ignore-rule
|
||||
* node hook-admin.mjs reset # remove all config + cache
|
||||
*
|
||||
* Designed to be invoked by the LLM from the reference/hooks.md flow.
|
||||
@@ -534,12 +536,13 @@ function addIgnoreFile(cwd, glob) {
|
||||
|
||||
function parseIgnoreValueArgs(args) {
|
||||
const positionals = [];
|
||||
const files = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
let reason = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const arg = String(args[i] || '');
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
@@ -550,8 +553,20 @@ function parseIgnoreValueArgs(args) {
|
||||
chunks.push(args[++i]);
|
||||
}
|
||||
reason = chunks.join(' ').trim();
|
||||
} else if (String(arg).startsWith('--reason=')) {
|
||||
reason = String(arg).slice('--reason='.length).trim();
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
reason = arg.slice('--reason='.length).trim();
|
||||
} else if (arg === '--file' || arg === '--files') {
|
||||
if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
|
||||
files.push(String(args[++i]).trim());
|
||||
} else if (arg.startsWith('--file=')) {
|
||||
files.push(arg.slice('--file='.length).trim());
|
||||
} else if (arg.startsWith('--files=')) {
|
||||
files.push(arg.slice('--files='.length).trim());
|
||||
} else if (arg.startsWith('--')) {
|
||||
// Otherwise a typo folds into the value: `ignore-value overused-font Inter
|
||||
// --shard` stored the value "inter --shard", which matches no finding, and
|
||||
// reported success. Matches `impeccable ignores add-value`.
|
||||
throw new Error(`Unknown ignore-value flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
@@ -561,6 +576,7 @@ function parseIgnoreValueArgs(args) {
|
||||
return {
|
||||
rule: String(rule || '').trim().toLowerCase(),
|
||||
value: normalizeIgnoreValue(valueParts.join(' ')),
|
||||
files: Array.from(new Set(files.filter(Boolean))),
|
||||
shared,
|
||||
local,
|
||||
reason,
|
||||
@@ -577,10 +593,19 @@ function addIgnoreValue(cwd, args) {
|
||||
throw new Error('Pass only one scope flag: --shared or --local');
|
||||
}
|
||||
|
||||
// A bare `*` would suppress the rule everywhere, which is ignore-rule's job and
|
||||
// not what a finding in one file justifies. detector.ignoreValues honours a
|
||||
// `files` scope, so require one — matching `impeccable ignores add-value`.
|
||||
if (parsed.value === '*' && parsed.files.length === 0) {
|
||||
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}.`);
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
|
||||
const key = `${parsed.rule}\0${parsed.value}`;
|
||||
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
|
||||
// Key on the file scope too: the same rule/value legitimately appears more than
|
||||
// once with different scopes, and a rule+value-only key overwrote them.
|
||||
const key = ignoreValueEntryKey({ rule: parsed.rule, value: parsed.value, files: parsed.files });
|
||||
const existing = config.ignoreValues.find((entry) => ignoreValueEntryKey(entry) === key);
|
||||
|
||||
if (existing) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
@@ -588,15 +613,17 @@ function addIgnoreValue(cwd, args) {
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (parsed.files.length) entry.files = parsed.files;
|
||||
entry.createdAt = new Date().toISOString();
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
|
||||
const target = writeDetectorConfig(cwd, config, { local });
|
||||
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
|
||||
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
const scopeSuffix = parsed.files.length ? ` scoped to ${parsed.files.join(', ')}` : '';
|
||||
return `Added ${parsed.rule}=${parsed.value}${scopeSuffix} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function reset(cwd) {
|
||||
|
||||
@@ -502,12 +502,15 @@ export function normalizeIgnoreValueEntries(entries) {
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
// Key order is rule, value, files, createdAt, reason and must stay that way:
|
||||
// normalizing runs on every write, so emitting a different order than the one
|
||||
// already on disk rewrites every untouched entry and churns the diff.
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
@@ -1465,16 +1468,17 @@ export function appendDesignSystemNote(text, scanOptions) {
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const fileIgnoreGuidance = opts.grouped
|
||||
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
|
||||
: `run \`${ignoreFileCommand}\``;
|
||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
||||
// for the path forever, which is far more than one noisy rule on a real UI
|
||||
// surface justifies, and it was previously the only option named here.
|
||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
||||
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,20 @@ export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session IDs become path segments (journals, snapshots, accept receipts,
|
||||
* preview manifests, generated component dirs). They arrive from CLI `--id`
|
||||
* arguments and HTTP payloads, so anything containing a separator or `..` must
|
||||
* be rejected before it reaches path.join, which would happily escape
|
||||
* `.impeccable/live/`. Real IDs are 8 hex chars; the tests use short slugs.
|
||||
*/
|
||||
export function safeSessionId(id) {
|
||||
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
|
||||
throw new Error('invalid session id: ' + id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
+269
-29
@@ -16,15 +16,63 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { withSourceLockSync } from './live/source-lock.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
findSvelteComponentManifest,
|
||||
inlineSvelteComponentAccept,
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
import {
|
||||
findVueComponentManifest,
|
||||
inlineVueComponentAccept,
|
||||
retireVueComponentSession,
|
||||
} from './live/vue-component.mjs';
|
||||
import { removeGenerationArtifacts } from './live/generation-publisher.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
|
||||
// value arriving over HTTP.
|
||||
const VARIANT_NUM_PATTERN = /^[0-9]{1,3}$/;
|
||||
|
||||
/**
|
||||
* A thrown accept/discard is a real failure, not a manual handoff.
|
||||
*
|
||||
* live/completion.mjs only classifies a result as `error` when it carries
|
||||
* `mode: 'error'`; anything else unhandled falls through to `agent_done` with a
|
||||
* successful ack, and reference/live.md then tells the agent to finish the edit
|
||||
* by hand. That is right for the documented fallback paths and wrong here: a
|
||||
* `source_locked` contention needs a retry (hand-editing races the publisher
|
||||
* holding the lock), and a crash needs surfacing, not a hand-applied guess.
|
||||
*/
|
||||
function operationFailure(err, extra = {}) {
|
||||
return { handled: false, mode: 'error', error: err.message, ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an unhandled preview-path result as a real failure.
|
||||
*
|
||||
* operationFailure only covers results built from a *thrown* error. The accept
|
||||
* implementations also return `{handled: false, error}` for their own checks
|
||||
* (variant missing, template empty, original text ambiguous), and those arrived
|
||||
* without `mode`, so completion.mjs classified them as agent_done and
|
||||
* reference/live.md routed the agent to "read file, find markers, edit".
|
||||
*
|
||||
* That handoff only makes sense for a plain wrapper session, which is the one
|
||||
* shape with markers in the user's source to edit. Component and isolated
|
||||
* artifact previews keep the source clean until Accept, so there is nothing to
|
||||
* hand-edit and an unhandled result is always a failure. `previewMode` is
|
||||
* exactly that discriminator: only the preview branches set it.
|
||||
*/
|
||||
function markPreviewFailure(result) {
|
||||
if (result?.handled === false && !result.mode && result.previewMode) {
|
||||
return { ...result, mode: 'error' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
@@ -63,7 +111,56 @@ Output (JSON):
|
||||
const isDiscard = args.includes('--discard');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
// `id` becomes a path segment (accept receipts, preview manifests, generated
|
||||
// component dirs). Reject separators and traversal here so one check covers
|
||||
// every downstream sink.
|
||||
try { safeSessionId(id); } catch { console.error('Invalid --id'); process.exit(1); }
|
||||
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
|
||||
// `variantNum` is interpolated into a RegExp and into the markup written back
|
||||
// to source. The browser and the /events schema both constrain it to digits;
|
||||
// enforce the same here, or `--variant '.*'` matches the `original` block
|
||||
// first and silently accepts the original while reporting success.
|
||||
if (!isDiscard && !VARIANT_NUM_PATTERN.test(variantNum)) {
|
||||
console.error('Invalid --variant');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requestedOperation = isDiscard ? 'discard' : 'accept';
|
||||
const priorReceipt = readAcceptReceipt(process.cwd(), id);
|
||||
if (priorReceipt) {
|
||||
const sameOperation = priorReceipt.operation === requestedOperation
|
||||
&& (isDiscard || String(priorReceipt.variantId) === String(variantNum));
|
||||
console.log(JSON.stringify(sameOperation
|
||||
? { ...priorReceipt.result, handled: true, alreadyApplied: true }
|
||||
: {
|
||||
// mode: 'error' is what marks this a real failure rather than a manual
|
||||
// handoff. Without it, live/completion.mjs classifies the reply as
|
||||
// agent_done and reference/live.md tells the agent to "read file, find
|
||||
// markers, edit" by hand — which would apply a second, conflicting
|
||||
// accept on top of the one the receipt already recorded.
|
||||
handled: false,
|
||||
mode: 'error',
|
||||
error: 'accept_receipt_conflict',
|
||||
priorOperation: priorReceipt.operation,
|
||||
priorVariantId: priorReceipt.variantId ?? null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const emitResult = (rawResult) => {
|
||||
const result = markPreviewFailure(rawResult);
|
||||
if (result?.handled !== false) {
|
||||
writeAcceptReceipt(process.cwd(), id, {
|
||||
operation: requestedOperation,
|
||||
variantId: isDiscard ? null : String(variantNum),
|
||||
result,
|
||||
});
|
||||
// The session is over: drop its staged revision artifacts. Leaving them
|
||||
// behind is what let a later marker search find a decoy instead of real
|
||||
// source. Only on success, so a failed accept can still be retried.
|
||||
removeGenerationArtifacts(id, process.cwd());
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
};
|
||||
|
||||
let paramValues = null;
|
||||
if (paramValuesRaw) {
|
||||
@@ -74,47 +171,111 @@ Output (JSON):
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
|
||||
const vueComponentManifest = found || svelteComponentManifest ? null : findVueComponentManifest(id, process.cwd());
|
||||
|
||||
if (!found && !svelteComponentManifest) {
|
||||
if (!found && !svelteComponentManifest && !vueComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (vueComponentManifest) {
|
||||
if (isDiscard) {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
console.log(JSON.stringify({
|
||||
handled: true,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
retireVueComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: vueComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
}));
|
||||
previewMode: 'vue-component',
|
||||
componentDir: vueComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), vueComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineVueComponentAccept(vueComponentManifest, variantNum, process.cwd()),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = {
|
||||
handled: false,
|
||||
error: err.message,
|
||||
result = operationFailure(err, {
|
||||
file: vueComponentManifest.sourceFile,
|
||||
sourceFile: vueComponentManifest.sourceFile,
|
||||
previewMode: 'vue-component',
|
||||
componentDir: vueComponentManifest.componentDir,
|
||||
carbonize: false,
|
||||
});
|
||||
}
|
||||
emitResult(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'discard:' + id,
|
||||
() => {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
return { handled: true };
|
||||
},
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err);
|
||||
}
|
||||
emitResult({
|
||||
...result,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = withSourceLockSync(
|
||||
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
|
||||
'accept:' + id,
|
||||
() => inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
),
|
||||
{ waitMs: ACCEPT_LOCK_WAIT_MS },
|
||||
);
|
||||
} catch (err) {
|
||||
result = operationFailure(err, {
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
|
||||
emitResult({ handled: result.handled !== false, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,10 +306,25 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (isDiscard) {
|
||||
const result = handleDiscard(id, lines, targetFile);
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
|
||||
let result;
|
||||
// handleDiscard takes the source lock, which throws SOURCE_LOCKED under
|
||||
// contention. Without this catch the CLI exits non-zero with empty stdout
|
||||
// and the agent gets no JSON to act on.
|
||||
try {
|
||||
result = handleDiscard(id, lines, targetFile);
|
||||
} catch (err) {
|
||||
emitResult(operationFailure(err, { file: relFile }));
|
||||
return;
|
||||
}
|
||||
emitResult({ handled: true, file: relFile, carbonize: false, ...result });
|
||||
} else {
|
||||
const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
let result;
|
||||
try {
|
||||
result = handleAccept(id, variantNum, lines, targetFile, paramValues);
|
||||
} catch (err) {
|
||||
emitResult(operationFailure(err, { file: relFile }));
|
||||
return;
|
||||
}
|
||||
const acceptedOriginalText = result.acceptedOriginalText || '';
|
||||
delete result.acceptedOriginalText;
|
||||
// Single-line attention-grabber when cleanup is required. The full
|
||||
@@ -167,7 +343,7 @@ Output (JSON):
|
||||
// Non-fatal; the buffer stays as-is and the user can discard later.
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
|
||||
emitResult({ handled: true, file: relFile, ...result });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +411,14 @@ function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalB
|
||||
// Discard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleDiscard(id, lines, targetFile) {
|
||||
function handleDiscard(id, _lines, targetFile) {
|
||||
return withSourceLockSync(targetFile, 'discard:' + id, () => {
|
||||
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
|
||||
return handleDiscardUnlocked(id, lines, targetFile);
|
||||
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
|
||||
}
|
||||
|
||||
function handleDiscardUnlocked(id, lines, targetFile) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -330,7 +513,24 @@ function reindentContent(contentLines, fromIndent, toIndent) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
|
||||
return withSourceLockSync(targetFile, 'accept:' + id, () => {
|
||||
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
|
||||
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
|
||||
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
|
||||
}
|
||||
|
||||
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
|
||||
const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
|
||||
if (built.handled === false) return built;
|
||||
fs.writeFileSync(targetFile, built.content, 'utf-8');
|
||||
return {
|
||||
carbonize: built.carbonize,
|
||||
acceptedOriginalText: built.acceptedOriginalText,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
|
||||
@@ -375,11 +575,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
...replacement,
|
||||
...lines.slice(replaceRange.end + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
|
||||
return {
|
||||
content: newLines.join('\n'),
|
||||
carbonize: needsCarbonize,
|
||||
acceptedOriginalText: originalContent.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function readSourceShadowPreviewMeta(content, id) {
|
||||
const escaped = escapeRegExp(id);
|
||||
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
|
||||
@@ -746,6 +949,21 @@ function detectCommentSyntax(filePath) {
|
||||
// File search (find the file containing session markers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `.impeccable` is the critical entry, and it is not cosmetic.
|
||||
*
|
||||
* Progressive publication stages each revision as `.impeccable/live/artifacts/
|
||||
* <id>-r<n>.<source-ext>`, and those artifacts carry the very marker this search
|
||||
* looks for. The walk reaches `.` for any project whose source is not under one
|
||||
* of the privileged dirs above (this repo's own site lives in `site/pages/`), and
|
||||
* dot-directories sort before letters, so the artifact was found *before* the
|
||||
* real file. isGeneratedFile then declined the accept, and the agent fell back to
|
||||
* carbonizing several hundred lines of stylesheet by hand.
|
||||
*
|
||||
* Impeccable's own state directory is never project source. Never search it.
|
||||
*/
|
||||
const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]);
|
||||
|
||||
function findSessionFile(id, cwd) {
|
||||
const marker = 'impeccable-variants-start ' + id;
|
||||
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
|
||||
@@ -786,7 +1004,7 @@ function searchDir(dir, query, seen, depth) {
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
|
||||
if (SEARCH_SKIP_DIRS.has(entry.name)) continue;
|
||||
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
|
||||
if (result) return result;
|
||||
}
|
||||
@@ -798,6 +1016,28 @@ function searchDir(dir, query, seen, depth) {
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function acceptReceiptPath(cwd, id) {
|
||||
return path.join(getLiveDir(cwd), 'accept-receipts', `${safeSessionId(id)}.json`);
|
||||
}
|
||||
|
||||
function readAcceptReceipt(cwd, id) {
|
||||
try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeAcceptReceipt(cwd, id, receipt) {
|
||||
const file = acceptReceiptPath(cwd, id);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const value = {
|
||||
id,
|
||||
...receipt,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(temporary, file);
|
||||
return value;
|
||||
}
|
||||
|
||||
function argVal(args, flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
||||
|
||||
+408
-103
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
|
||||
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
@@ -38,6 +40,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/artifacts/',
|
||||
'.impeccable/live/accept-receipts/',
|
||||
'.impeccable/live/locks/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
@@ -46,10 +51,15 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'.impeccable-live.json',
|
||||
'.impeccable-live/',
|
||||
'app/.impeccable-live/',
|
||||
'src/.impeccable-live/',
|
||||
'node_modules/.impeccable-live/',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
'src/lib/impeccable/[0-9a-f]*/',
|
||||
'plugins/impeccable-live.client.ts',
|
||||
'app/plugins/impeccable-live.client.ts',
|
||||
'src/plugins/impeccable-live.client.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -113,6 +123,7 @@ Output (JSON):
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -120,6 +131,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
@@ -145,13 +162,28 @@ Output (JSON):
|
||||
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
||||
process.exit(1);
|
||||
}
|
||||
const gitIgnore = ensureLiveGitIgnores(process.cwd());
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'nuxt',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
@@ -175,12 +207,12 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
|
||||
const target = resolveIgnoreTarget(cwd);
|
||||
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
||||
const block = [
|
||||
IGNORE_MARKER_OPEN,
|
||||
...LIVE_IGNORE_PATTERNS,
|
||||
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
|
||||
IGNORE_MARKER_CLOSE,
|
||||
].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
|
||||
@@ -202,10 +234,119 @@ export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
||||
mode: target.mode,
|
||||
changed: updated !== existing,
|
||||
patterns: [...LIVE_IGNORE_PATTERNS],
|
||||
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nuxt adapter
|
||||
//
|
||||
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
|
||||
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
|
||||
// generated, dev-only, and outside user-authored source: Live creates one
|
||||
// marked .client.ts plugin on start and removes it on stop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function detectNuxtProject(cwd = process.cwd()) {
|
||||
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
|
||||
?.name;
|
||||
if (!configFile) return null;
|
||||
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = '';
|
||||
if (literalSrcDir) {
|
||||
const candidate = literalSrcDir[2]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
|
||||
appDir = normalized === '.' ? '' : normalized;
|
||||
}
|
||||
} else if (
|
||||
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|
||||
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
|
||||
) {
|
||||
appDir = 'app';
|
||||
}
|
||||
|
||||
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
|
||||
return { configFile, appDir, pluginFile };
|
||||
}
|
||||
|
||||
export function buildNuxtPlugin(port) {
|
||||
return `/* ${NUXT_PLUGIN_MARKER} */
|
||||
const liveSrc = 'http://localhost:${port}/live.js';
|
||||
const liveSelector = 'script[data-impeccable-live-nuxt]';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.dev || typeof document === 'undefined') return;
|
||||
|
||||
const expectedSrc = new URL(liveSrc, window.location.href).href;
|
||||
let script = document.querySelector(liveSelector);
|
||||
if (script?.src === expectedSrc) return;
|
||||
script?.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = liveSrc;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveNuxt = '';
|
||||
document.head.appendChild(script);
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
if (script?.isConnected) script.remove();
|
||||
});
|
||||
});
|
||||
/* /${NUXT_PLUGIN_MARKER} */
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
|
||||
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buildNuxtPlugin(port);
|
||||
fs.mkdirSync(path.dirname(absFile), { recursive: true });
|
||||
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
inserted: true,
|
||||
changed: content !== existing,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
if (!fs.existsSync(absFile)) {
|
||||
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
|
||||
}
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
if (!content.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
removed: false,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.unlinkSync(absFile);
|
||||
const pluginDir = path.dirname(absFile);
|
||||
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
|
||||
return { file: project.pluginFile, removed: true };
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
|
||||
+46
-14
@@ -27,7 +27,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
|
||||
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
@@ -38,8 +38,8 @@ function readServerInfo() {
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data, sourceEventType }) {
|
||||
return { token, id, type, message, file, data, sourceEventType };
|
||||
}
|
||||
|
||||
export function manualApplyPollBanner(event = {}) {
|
||||
@@ -152,7 +152,14 @@ export async function waitForEventAck(base, token, eventId, {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
export async function fetchNextEvent(base, token, {
|
||||
totalDeadline,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs = PER_REQUEST_TIMEOUT_MS,
|
||||
leaseMs = DEFAULT_EVENT_LEASE_MS,
|
||||
signal,
|
||||
} = {}) {
|
||||
while (true) {
|
||||
if (totalDeadline && Date.now() >= totalDeadline) {
|
||||
return { type: 'timeout' };
|
||||
@@ -161,8 +168,15 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
const remaining = totalDeadline
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
|
||||
const slice = Math.min(Math.max(remaining, 1000), perRequestTimeoutMs);
|
||||
const query = new URLSearchParams({
|
||||
token,
|
||||
timeout: String(slice),
|
||||
leaseMs: String(leaseMs),
|
||||
});
|
||||
const normalizedTypes = normalizePollTypes(resolveTypes ? await resolveTypes() : types);
|
||||
if (normalizedTypes.length > 0) query.set('types', normalizedTypes.join(','));
|
||||
const res = await fetch(`${base}/poll?${query}`, { signal });
|
||||
|
||||
if (res.status === 401) {
|
||||
const err = new Error('Authentication failed. The server token may have changed.');
|
||||
@@ -202,11 +216,17 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
await completeAcceptHandling(event, base, token);
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function completeAcceptHandling(event, base, token) {
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
sourceEventType: event.type,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
@@ -217,7 +237,6 @@ export async function augmentEventWithAcceptHandling(event, base, token) {
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -245,9 +264,9 @@ export function printPollEvent(event) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000, types, resolveTypes, perRequestTimeoutMs } = {}) {
|
||||
const deadline = Date.now() + totalTimeout;
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -258,11 +277,14 @@ export async function runPollStream(base, token, {
|
||||
ackTimeoutMs = 600_000,
|
||||
ackPollIntervalMs = 400,
|
||||
shouldContinue = () => true,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs,
|
||||
} = {}) {
|
||||
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
|
||||
|
||||
while (shouldContinue()) {
|
||||
const event = await fetchNextEvent(base, token);
|
||||
const event = await fetchNextEvent(base, token, { types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -322,14 +344,17 @@ Modes:
|
||||
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--types=A,B Lease only these event types
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
--help Show this help message
|
||||
|
||||
Harness note:
|
||||
Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
|
||||
--stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
|
||||
Default one-shot mode is the primary contract, including Codex foreground polling.
|
||||
Claude Code may run it as a background task; Cursor uses a background terminal with exit notification.
|
||||
--stream is retained for harnesses with measured, reliable incremental stdout.
|
||||
Do not use --stream on Cursor.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -360,23 +385,30 @@ Harness note:
|
||||
}
|
||||
|
||||
const streamMode = args.includes('--stream');
|
||||
const typesArg = args.find((a) => a.startsWith('--types='));
|
||||
const types = normalizePollTypes(typesArg ? typesArg.slice('--types='.length) : null);
|
||||
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
|
||||
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
|
||||
|
||||
try {
|
||||
if (streamMode) {
|
||||
await runPollStream(base, info.token, { ackTimeoutMs });
|
||||
await runPollStream(base, info.token, { ackTimeoutMs, types });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
|
||||
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
|
||||
await runPollOnce(base, info.token, { totalTimeout });
|
||||
await runPollOnce(base, info.token, { totalTimeout, types });
|
||||
} catch (err) {
|
||||
handlePollError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePollTypes(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || '').split(',');
|
||||
return [...new Set(values.map((type) => String(type).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
prepareGenerationArtifact,
|
||||
publishGenerationArtifact,
|
||||
} from './live/generation-publisher.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const result = args.includes('--prepare')
|
||||
? prepareGenerationArtifact({
|
||||
id: arg(args, '--id'),
|
||||
sourceFile: arg(args, '--file'),
|
||||
})
|
||||
: publishGenerationArtifact({
|
||||
id: arg(args, '--id'),
|
||||
epoch: Number(arg(args, '--epoch')),
|
||||
sourceFile: arg(args, '--file'),
|
||||
artifactFile: arg(args, '--artifact'),
|
||||
expectedSourceHash: arg(args, '--expected-source-hash'),
|
||||
arrivedVariants: optionalNumber(arg(args, '--arrived')),
|
||||
expectedVariants: optionalNumber(arg(args, '--expected')),
|
||||
publicationKind: arg(args, '--kind'),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 2;
|
||||
|
||||
function arg(values, name) {
|
||||
const index = values.indexOf(name);
|
||||
return index >= 0 ? values[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : undefined;
|
||||
}
|
||||
+301
-30
@@ -29,7 +29,9 @@ import {
|
||||
resolveLiveBrowserScriptParts,
|
||||
} from './live/browser-script-parts.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { runGenerationPreflight } from './live/generation-preflight.mjs';
|
||||
import { validateEvent } from './live/event-validation.mjs';
|
||||
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
removeAllSvelteComponentSessions,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { removeAllVueComponentSessions } from './live/vue-component.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
@@ -63,6 +66,10 @@ const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
// The browser checkpoints for several unrelated reasons (see checkpointPayload
|
||||
// in live-browser.js). Only these two report that variant availability changed,
|
||||
// and only they may drive variant_progress / the *_reviewable phases.
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
@@ -156,29 +163,148 @@ function restorePendingEventsFromStore() {
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
for (const entry of state.pendingEvents) {
|
||||
if (entry.leaseUntil && entry.leaseUntil > now) continue;
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
function findAvailablePendingEvent(now = Date.now(), types = null) {
|
||||
return selectAvailablePendingEvent(state.pendingEvents, { now, types });
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
async function leaseEvent(entry, leaseMs) {
|
||||
// Claim the entry before awaiting anything. prepareGenerateEventForLease
|
||||
// yields to the event loop, and selectAvailablePendingEvent only skips
|
||||
// entries whose lease is in the future — an unclaimed entry would be handed
|
||||
// to a second poll in that window and generated twice.
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
await prepareGenerateEventForLease(entry);
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
// Re-stamp so the lease window starts when the agent actually receives the
|
||||
// work, not when scaffolding began.
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
recordGenerateDelivery(entry);
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
function recordGenerateDelivery(entry) {
|
||||
const event = entry?.event;
|
||||
if (!event || event.type !== 'generate' || event.generationReadyAt) return;
|
||||
const at = Date.now();
|
||||
entry.event = { ...event, generationReadyAt: at };
|
||||
state.sessionStore?.appendEvent(entry.event);
|
||||
recordAgentPhase(event.id, 'generation_ready', { at });
|
||||
}
|
||||
|
||||
async function prepareGenerateEventForLease(entry) {
|
||||
const event = entry?.event;
|
||||
if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
|
||||
|
||||
recordAgentPhase(event.id, 'picked_up');
|
||||
recordAgentPhase(event.id, 'scaffolding');
|
||||
const result = await runGenerationPreflight(event, {
|
||||
cwd: process.cwd(),
|
||||
scriptsDir: __dirname,
|
||||
});
|
||||
entry.event = {
|
||||
...event,
|
||||
scaffoldAttempted: true,
|
||||
scaffoldDurationMs: result.durationMs ?? null,
|
||||
...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
|
||||
};
|
||||
state.sessionStore?.appendEvent(entry.event);
|
||||
recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
|
||||
durationMs: result.durationMs ?? null,
|
||||
previewMode: result.scaffold?.previewMode || 'source',
|
||||
});
|
||||
}
|
||||
|
||||
function recordAgentPhase(id, phase, details = {}) {
|
||||
if (!id) return;
|
||||
const event = {
|
||||
type: 'agent_phase',
|
||||
id,
|
||||
phase,
|
||||
at: Date.now(),
|
||||
...details,
|
||||
};
|
||||
state.sessionStore?.appendEvent(event);
|
||||
broadcast(event);
|
||||
}
|
||||
|
||||
function recordGenerationCheckpoint(event) {
|
||||
if (!event?.id || event.type !== 'checkpoint') return;
|
||||
if (generationIsFenced(event.id)) return;
|
||||
// Only checkpoints that report a change in variant availability are
|
||||
// generation progress. The browser also checkpoints for durability on Tune
|
||||
// slider drags, resumes, and anchor recovery; treating those as progress
|
||||
// echoed `variant_progress` straight back to the browser that sent it, which
|
||||
// remounts the component preview mid-drag (reverting the user's live param
|
||||
// edit and detaching the popover's element), and permanently latched the
|
||||
// *_reviewable phases from the wrong trigger, corrupting generation timings.
|
||||
if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) return;
|
||||
const arrived = Number(event.arrivedVariants) || 0;
|
||||
const expected = Number(event.expectedVariants) || 0;
|
||||
if (arrived <= 0 || expected <= 0) return;
|
||||
const previewMode = event.previewMode || 'source';
|
||||
const previewFile = event.previewFile || event.file;
|
||||
if (previewFile) {
|
||||
broadcast({
|
||||
type: 'variant_progress',
|
||||
id: event.id,
|
||||
file: previewFile,
|
||||
sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
|
||||
previewFile,
|
||||
previewMode,
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: expected,
|
||||
publicationKind: event.publicationKind || 'variants',
|
||||
});
|
||||
}
|
||||
const details = {
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: expected,
|
||||
checkpointReason: event.reason || null,
|
||||
};
|
||||
const at = Date.now();
|
||||
if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
|
||||
recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
|
||||
}
|
||||
if (arrived >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
|
||||
recordAgentPhase(event.id, 'second_reviewable', { ...details, at });
|
||||
}
|
||||
if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
|
||||
recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
|
||||
}
|
||||
}
|
||||
|
||||
function generationIsFenced(id) {
|
||||
if (!state.sessionStore || !id) return false;
|
||||
try {
|
||||
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
|
||||
return snapshot?.generationCanceled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generationPhaseAlreadyRecorded(id, phase) {
|
||||
if (!state.sessionStore) return false;
|
||||
try {
|
||||
const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
|
||||
return !!snapshot?.generationTimings?.[phase];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id, sourceEventType) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
const idx = state.pendingEvents.findIndex((entry) => (
|
||||
entry.event?.id === id
|
||||
&& (!sourceEventType || entry.event?.type === sourceEventType)
|
||||
));
|
||||
if (idx === -1) return false;
|
||||
const acknowledged = state.pendingEvents[idx].event;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
@@ -187,9 +313,39 @@ function acknowledgePendingEvent(id) {
|
||||
return acknowledged;
|
||||
}
|
||||
|
||||
function findPendingEventById(id) {
|
||||
function releasePendingEvent(id, sourceEventType) {
|
||||
const entry = state.pendingEvents.find((item) => (
|
||||
item.event?.id === id
|
||||
&& (!sourceEventType || item.event?.type === sourceEventType)
|
||||
));
|
||||
if (!entry) return null;
|
||||
entry.leaseUntil = 0;
|
||||
scheduleLeaseFlush();
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function retirePendingGeneration(id) {
|
||||
if (!id) return 0;
|
||||
let retired = 0;
|
||||
for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
|
||||
const event = state.pendingEvents[index]?.event;
|
||||
if (event?.id !== id || event.type !== 'generate') continue;
|
||||
state.pendingEvents.splice(index, 1);
|
||||
retired += 1;
|
||||
}
|
||||
if (retired > 0) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
function findPendingEventById(id, sourceEventType) {
|
||||
if (!id) return null;
|
||||
const entry = state.pendingEvents.find((item) => item.event?.id === id);
|
||||
const entry = state.pendingEvents.find((item) => (
|
||||
item.event?.id === id
|
||||
&& (!sourceEventType || item.event?.type === sourceEventType)
|
||||
));
|
||||
return entry?.event || null;
|
||||
}
|
||||
|
||||
@@ -198,7 +354,7 @@ function summarizePendingEventForStatus(entry) {
|
||||
const summary = {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leased: isLeased(entry),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
};
|
||||
if (event.type === 'manual_edit_apply') {
|
||||
@@ -224,7 +380,13 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
arrivedVariants: snapshot.arrivedVariants ?? 0,
|
||||
visibleVariant: snapshot.visibleVariant ?? null,
|
||||
checkpointRevision: snapshot.checkpointRevision ?? 0,
|
||||
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
|
||||
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
|
||||
paramValues: snapshot.paramValues || {},
|
||||
paramsPublished: snapshot.paramsPublished === true,
|
||||
generationPhase: snapshot.generationPhase ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,24 +431,46 @@ function scheduleLeaseFlush() {
|
||||
function flushPendingPolls() {
|
||||
let changed = false;
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
let pollIndex = -1;
|
||||
let entry = null;
|
||||
for (let index = 0; index < state.pendingPolls.length; index += 1) {
|
||||
const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
|
||||
if (!candidate) continue;
|
||||
pollIndex = index;
|
||||
entry = candidate;
|
||||
break;
|
||||
}
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
broadcastAgentPollingIfChanged();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
const [poll] = state.pendingPolls.splice(pollIndex, 1);
|
||||
// leaseEvent is async (it may scaffold source), but it claims the entry
|
||||
// synchronously, so the next loop iteration will not re-select it. Resolve
|
||||
// the poll when the lease settles rather than awaiting here, so one slow
|
||||
// scaffold never delays the other parked polls. On the exceptional failure
|
||||
// path, answer `timeout` so the agent re-polls; the claim stays until the
|
||||
// lease expires, which keeps a deterministic failure from hot-looping.
|
||||
leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
|
||||
console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
poll.resolve({ type: 'timeout' });
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
if (changed) broadcastAgentPollingIfChanged();
|
||||
}
|
||||
|
||||
function isLeased(entry) {
|
||||
return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
|
||||
}
|
||||
|
||||
function agentPollingConnected() {
|
||||
const now = Date.now();
|
||||
return state.pendingPolls.length > 0
|
||||
|| state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
|
||||
// A leased event only proves that a poll returned once. The foreground task
|
||||
// may have ended immediately afterward, so only an actively waiting poll is
|
||||
// evidence that steering can wake the task right now.
|
||||
return state.pendingPolls.length > 0;
|
||||
}
|
||||
|
||||
function broadcastAgentPollingIfChanged() {
|
||||
@@ -689,6 +873,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
owner: typeof msg.owner === 'string' ? msg.owner : undefined,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
@@ -698,6 +891,10 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type === 'accept' || msg.type === 'discard') {
|
||||
retirePendingGeneration(msg.id);
|
||||
}
|
||||
recordGenerationCheckpoint(msg);
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
@@ -738,6 +935,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
// Agent poll endpoints (unchanged from WS version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePollTypes(value) {
|
||||
if (!value) return null;
|
||||
const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
|
||||
return types.length > 0 ? new Set(types) : null;
|
||||
}
|
||||
|
||||
function handlePollGet(req, res, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) {
|
||||
@@ -748,13 +951,25 @@ function handlePollGet(req, res, url) {
|
||||
state.lastPollAt = Date.now();
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
const types = parsePollTypes(url.searchParams.get('types'));
|
||||
const available = findAvailablePendingEvent(Date.now(), types);
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
// Do not await inline: leaseEvent may scaffold source, and this handler runs
|
||||
// on the server's only thread. The client can disconnect during that window,
|
||||
// so check the socket before replying.
|
||||
leaseEvent(available, leaseMs).then((event) => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}, (error) => {
|
||||
console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const poll = { resolve, leaseMs, types };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
@@ -783,12 +998,15 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
if (!file || typeof file !== 'string') return { file };
|
||||
const normalized = file.split(path.sep).join('/');
|
||||
const base = { file: normalized };
|
||||
if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
|
||||
if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
|
||||
const metadataFile = normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')) return base;
|
||||
|
||||
let full;
|
||||
try {
|
||||
full = path.resolve(process.cwd(), normalized);
|
||||
full = path.resolve(process.cwd(), metadataFile);
|
||||
const rel = path.relative(process.cwd(), full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
|
||||
} catch {
|
||||
@@ -797,18 +1015,47 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
|
||||
if (!['svelte-component', 'vue-component'].includes(manifest?.previewMode)
|
||||
|| !manifest.sourceFile) return base;
|
||||
return {
|
||||
file: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
|
||||
previewFile: normalized,
|
||||
previewMode: 'svelte-component',
|
||||
previewMode: manifest.previewMode,
|
||||
};
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
|
||||
const entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
|
||||
const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
|
||||
if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
|
||||
if (msg.type === 'complete') {
|
||||
if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
|
||||
return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
|
||||
}
|
||||
if (msg.type === 'steer_done') return 'steer';
|
||||
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
|
||||
// New pollers send sourceEventType explicitly; default to generate only for
|
||||
// older callers so a late worker cannot acknowledge a queued Accept.
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
|
||||
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
|
||||
// never sets sourceEventType on it (the poller is a fresh process that cannot
|
||||
// know what it leased). Returning undefined here makes acknowledgePendingEvent
|
||||
// match *any* event for this id: a stale generate worker's failure silently
|
||||
// consumed the user's queued Accept, which was then never delivered to any
|
||||
// agent and left the browser in SAVING forever. Attribute the failure to the
|
||||
// event this agent actually holds a lease on, and otherwise to `generate` —
|
||||
// never to a wildcard. If that generate was already retired by an Accept, the
|
||||
// ack simply finds no match, which is the correct outcome for a stale reply.
|
||||
if (msg.type === 'error') {
|
||||
return entriesForId.find(isLeased)?.event?.type || 'generate';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
@@ -869,7 +1116,23 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
|
||||
return;
|
||||
}
|
||||
const pendingEventBeforeAck = findPendingEventById(msg.id);
|
||||
const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
|
||||
if (msg.type === 'retry') {
|
||||
const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
|
||||
if (!releasedEvent) {
|
||||
res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
|
||||
id: msg.id,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
flushPendingPolls();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, released: true }));
|
||||
return;
|
||||
}
|
||||
const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
|
||||
if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
|
||||
&& !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
@@ -879,7 +1142,7 @@ function handlePollPost(req, res) {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const acknowledgedEvent = acknowledgePendingEvent(msg.id);
|
||||
const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
|
||||
let skipJournalReply = false;
|
||||
let existingSession = null;
|
||||
if (!acknowledgedEvent && state.sessionStore && msg.id) {
|
||||
@@ -971,6 +1234,11 @@ function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
|
||||
}
|
||||
try {
|
||||
removeAllVueComponentSessions(process.cwd());
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Vue component session cleanup failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
@@ -1083,7 +1351,10 @@ if (args.includes('--background')) {
|
||||
process.exit(0);
|
||||
}
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
// The detached child is typically listening in 35-45ms. A 200ms polling
|
||||
// floor dominated configured cold Live startup; poll cheaply and return
|
||||
// as soon as the child has written its ready record.
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
}
|
||||
console.error('Timed out waiting for live server to start.');
|
||||
process.exit(1);
|
||||
|
||||
@@ -37,15 +37,19 @@ export async function statusCli() {
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: manualApply
|
||||
? manualApplyResumeHint(manualApply)
|
||||
: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
recoveryHint: recoveryHint({ server, manualApply }),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function recoveryHint({ server, manualApply }) {
|
||||
if (manualApply) return manualApplyResumeHint(manualApply);
|
||||
if (server) {
|
||||
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
|
||||
}
|
||||
return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
|
||||
}
|
||||
|
||||
function findPendingManualApply(server, activeSessions) {
|
||||
const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
|
||||
if (fromServer) return fromServer;
|
||||
|
||||
+65
-17
@@ -20,6 +20,11 @@ import {
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import {
|
||||
buildVueComponentCssAuthoring,
|
||||
scaffoldVueComponentSession,
|
||||
shouldUseVueComponentInjection,
|
||||
} from './live/vue-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -160,11 +165,29 @@ The agent should insert variant HTML at insertLine.`);
|
||||
if (filtered.length === 1) {
|
||||
match = filtered[0];
|
||||
} else if (filtered.length === 0) {
|
||||
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
|
||||
// browser-side textContent doesn't appear literally in source. Fall
|
||||
// back to first-match rather than refusing — this is the same
|
||||
// behavior unmodified callers see, just preserved.
|
||||
match = candidates[0];
|
||||
const normalizedText = String(text).replace(/\s+/g, ' ').trim();
|
||||
if (normalizedText.length < 8) {
|
||||
// Very short labels cannot disambiguate siblings reliably. Preserve
|
||||
// the legacy behavior for these low-information picker events.
|
||||
match = candidates[0];
|
||||
} else {
|
||||
// Rendered text that is absent from every candidate usually means
|
||||
// the source uses expressions or component props. Picking the first
|
||||
// same-class sibling silently edits the wrong instance (observed on
|
||||
// Astro result cards), so stop and surface every candidate instead.
|
||||
console.error(JSON.stringify({
|
||||
error: 'element_ambiguous',
|
||||
fallback: 'agent-driven',
|
||||
reason: 'rendered_text_not_in_source',
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
candidates: candidates.map((c) => ({
|
||||
startLine: c.startLine + 1,
|
||||
endLine: c.endLine + 1,
|
||||
})),
|
||||
hint: 'Rendered text does not occur in any matching source branch. The element may use dynamic props or expressions; inspect the candidates and wrap the intended instance manually.',
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
|
||||
// rather than pick wrong, and hand the agent the candidate locations
|
||||
@@ -207,6 +230,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// Strip only the COMMON minimum leading whitespace across the picked lines;
|
||||
// `deindentContent` on the accept side already mirrors this convention.
|
||||
let originalLines = lines.slice(startLine, endLine + 1);
|
||||
const sourceOriginalLines = [...originalLines];
|
||||
|
||||
// Buffer-aware "original" content: if the user has pending manual edits for
|
||||
// this page whose originalText appears in the picked source range, apply
|
||||
@@ -269,6 +293,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
const useVueComponent = !useSvelteComponent && shouldUseVueComponentInjection(targetFile);
|
||||
const useFrameworkComponent = useSvelteComponent || useVueComponent;
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -288,7 +314,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
// replacement range to include the wrapper's `<div>` open / close lines
|
||||
// so the entire scaffold gets removed cleanly.
|
||||
const wrapperLines = isJsx ? [
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
@@ -299,7 +325,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
indent + '</div>',
|
||||
] : [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '"' + ' ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -315,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let vueSession = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -334,6 +361,23 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (useVueComponent) {
|
||||
// Nuxt route-module HMR can invalidate the active page while a generated
|
||||
// wrapper is only partially written. Stage real Vue SFCs in an app-local
|
||||
// dev module tree and leave the route untouched until Accept.
|
||||
vueSession = scaffoldVueComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
sourceStartLine: startLine + 1,
|
||||
sourceEndLine: endLine + 1,
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), vueSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -356,15 +400,19 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const vueComponentAuthoring = useVueComponent ? buildVueComponentCssAuthoring(count) : null;
|
||||
const componentSession = svelteSession || vueSession;
|
||||
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : useVueComponent ? 'vue-component' : undefined;
|
||||
const previewMode = componentPreviewMode;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: outputRelFile,
|
||||
sourceFile: useSvelteComponent ? relTargetFile : undefined,
|
||||
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
|
||||
componentDir: svelteSession?.componentDir,
|
||||
propContract: svelteSession?.propContract,
|
||||
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
@@ -374,10 +422,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: outputEndLine, // 1-indexed
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
|
||||
styleTag: useSvelteComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
|
||||
styleMode: componentPreviewMode || styleMode.mode,
|
||||
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: svelteComponentAuthoring || vueComponentAuthoring || buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
* - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
|
||||
* - Enter the poll loop: `node live-poll.mjs`
|
||||
* - Enter the harness-native poll loop: `node live-poll.mjs`
|
||||
*
|
||||
* Usage:
|
||||
* node live.mjs # Prepare everything, print JSON, exit
|
||||
@@ -40,6 +40,7 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- Prepares the harness-native foreground/background poll loop
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// A preview whose variants live in component modules rather than in the user's
|
||||
// source. These leave no markers in the real file, so a failed accept gives the
|
||||
// agent nothing to hand-edit and must be reported as a failure rather than
|
||||
// reference/live.md's manual-cleanup handoff. Previously only `svelte-component`
|
||||
// was special-cased, so the same failure on a Vue preview read as success.
|
||||
const PREVIEW_MODES_WITHOUT_SOURCE_MARKERS = new Set([
|
||||
'svelte-component',
|
||||
'vue-component',
|
||||
]);
|
||||
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
|
||||
if (eventType === 'accept' && PREVIEW_MODES_WITHOUT_SOURCE_MARKERS.has(acceptResult?.previewMode)) return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,15 @@ export function validateEvent(msg) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'agent_phase':
|
||||
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
|
||||
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
|
||||
return 'agent_phase: missing or malformed phase';
|
||||
}
|
||||
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
|
||||
return 'agent_phase: durationMs must be a non-negative number';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -131,6 +140,12 @@ export function validateEvent(msg) {
|
||||
if (msg.message.length > 4000) return 'steer: message too long';
|
||||
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
|
||||
return null;
|
||||
case 'carbonize_cleanup':
|
||||
if (!isValidId(msg.id)) return 'carbonize_cleanup: missing or malformed id';
|
||||
if (!isValidId(msg.sessionId)) return 'carbonize_cleanup: missing or malformed sessionId';
|
||||
if (!msg.file || typeof msg.file !== 'string') return 'carbonize_cleanup: missing file';
|
||||
if (!isValidVariantId(String(msg.variantId))) return 'carbonize_cleanup: missing or malformed variantId';
|
||||
return null;
|
||||
default:
|
||||
return 'Unknown event type: ' + msg.type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
if (!target.elementId && !target.classes) return null;
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold the source for a generate event before handing it to an agent.
|
||||
*
|
||||
* Async on purpose. This spawns `live-wrap.mjs`, which walks the project's
|
||||
* source tree and can take seconds (measured at ~7.6s on a large repo when the
|
||||
* element is not found, with a 15s ceiling). The live server is single-threaded
|
||||
* and calls this while leasing a poll, so a synchronous spawn froze the whole
|
||||
* server for that entire window: Accept and Discard POSTs, SSE progress
|
||||
* broadcasts, and every other poll stalled behind it.
|
||||
*/
|
||||
export async function runGenerationPreflight(event, {
|
||||
cwd = process.cwd(),
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const { stdout } = await execFileImpl(process.execPath, command.args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
error: compactError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function replaceTarget(event) {
|
||||
return normalizeTarget(event.element || {});
|
||||
}
|
||||
|
||||
function insertTarget(event) {
|
||||
return {
|
||||
...normalizeTarget(event.insert?.anchor || {}),
|
||||
position: event.insert?.position === 'before' ? 'before' : 'after',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTarget(target) {
|
||||
const classes = Array.isArray(target.classes)
|
||||
? target.classes.join(' ')
|
||||
: String(target.classes || '').trim();
|
||||
const text = typeof target.textContent === 'string'
|
||||
? target.textContent.trim().slice(0, 80)
|
||||
: '';
|
||||
return {
|
||||
elementId: target.id || target.elementId || undefined,
|
||||
classes: classes || undefined,
|
||||
tag: target.tagName || target.tag || undefined,
|
||||
text: text || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactError(error) {
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : '';
|
||||
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
|
||||
return String(message).slice(0, 500);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createLiveSessionStore } from './session-store.mjs';
|
||||
import { withSourceLockSync } from './source-lock.mjs';
|
||||
import { getLiveDir, safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's staged revision artifacts.
|
||||
*
|
||||
* Nothing used to remove these, and they are the reason a Live accept could
|
||||
* resolve to the wrong file: `<id>-r<n>.<source-ext>` carries the session marker,
|
||||
* so it is a decoy for any marker search that walks the project. live-accept no
|
||||
* longer searches `.impeccable`, but the artifacts should not outlive the session
|
||||
* they belong to either. Called on accept and discard.
|
||||
*/
|
||||
export function removeGenerationArtifacts(id, cwd = process.cwd()) {
|
||||
let removed = 0;
|
||||
try { safeSessionId(id); } catch { return removed; }
|
||||
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(artifactDir); } catch { return removed; }
|
||||
for (const name of entries) {
|
||||
if (!name.startsWith(id + '-r')) continue;
|
||||
try { fs.rmSync(path.join(artifactDir, name), { force: true }); removed += 1; } catch { /* best effort */ }
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) {
|
||||
if (!id) return failure('missing_session_id');
|
||||
if (!sourceFile) return failure('missing_file');
|
||||
const requestedPath = resolveInside(cwd, sourceFile);
|
||||
if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project');
|
||||
|
||||
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
|
||||
if (componentTarget?.error) return componentTarget;
|
||||
const sourcePath = componentTarget?.sourcePath || requestedPath;
|
||||
|
||||
try {
|
||||
return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => {
|
||||
const store = createLiveSessionStore({ cwd, sessionId: id });
|
||||
const snapshot = store.getSnapshot(id, { includeCompleted: true });
|
||||
if (!snapshot?.updatedAt) return failure('session_missing');
|
||||
if (snapshot.generationCanceled === true) {
|
||||
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
|
||||
}
|
||||
const source = fs.readFileSync(sourcePath, 'utf-8');
|
||||
const artifactBase = source;
|
||||
const revision = Number(snapshot.publishedRevision || 0) + 1;
|
||||
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
|
||||
if (componentTarget) {
|
||||
return prepareComponentArtifact({
|
||||
id,
|
||||
revision,
|
||||
snapshot,
|
||||
source,
|
||||
sourcePath,
|
||||
requestedPath,
|
||||
target: componentTarget,
|
||||
artifactDir,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
const extension = path.extname(sourcePath) || '.html';
|
||||
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
fs.writeFileSync(artifactPath, artifactBase, 'utf-8');
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch: Number(snapshot.generationEpoch || 1),
|
||||
revision,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
artifactFile: relative(cwd, artifactPath),
|
||||
expectedSourceHash: sha256(source),
|
||||
};
|
||||
}, { cwd });
|
||||
} catch (error) {
|
||||
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
|
||||
return failure('prepare_failed', { message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
export function publishGenerationArtifact({
|
||||
id,
|
||||
epoch,
|
||||
sourceFile,
|
||||
artifactFile,
|
||||
expectedSourceHash,
|
||||
arrivedVariants,
|
||||
expectedVariants,
|
||||
publicationKind,
|
||||
cwd = process.cwd(),
|
||||
} = {}) {
|
||||
if (!id) return failure('missing_session_id');
|
||||
if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch');
|
||||
if (!sourceFile || !artifactFile) return failure('missing_file');
|
||||
if (publicationKind && !['variants', 'params'].includes(publicationKind)) {
|
||||
return failure('invalid_publication_kind');
|
||||
}
|
||||
|
||||
const requestedPath = resolveInside(cwd, sourceFile);
|
||||
const artifactPath = resolveInside(cwd, artifactFile);
|
||||
if (!requestedPath || !artifactPath) return failure('path_outside_project');
|
||||
if (!fs.existsSync(requestedPath)) return failure('source_missing');
|
||||
if (!fs.existsSync(artifactPath)) return failure('artifact_missing');
|
||||
|
||||
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
|
||||
if (componentTarget?.error) return componentTarget;
|
||||
const artifactManifest = readJson(artifactPath);
|
||||
const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode);
|
||||
if (Boolean(componentTarget) !== isComponentArtifact) {
|
||||
return failure('artifact_preview_mode_mismatch');
|
||||
}
|
||||
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
|
||||
return failure('artifact_preview_mode_mismatch');
|
||||
}
|
||||
const sourcePath = componentTarget?.sourcePath || requestedPath;
|
||||
|
||||
try {
|
||||
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
|
||||
const store = createLiveSessionStore({ cwd, sessionId: id });
|
||||
const snapshot = store.getSnapshot(id, { includeCompleted: true });
|
||||
if (!snapshot?.updatedAt) return failure('session_missing');
|
||||
const stale = staleGenerationFailure(snapshot, epoch);
|
||||
if (stale) return stale;
|
||||
|
||||
const current = fs.readFileSync(sourcePath, 'utf-8');
|
||||
const currentHash = sha256(current);
|
||||
if (!expectedSourceHash || currentHash !== expectedSourceHash) {
|
||||
return failure('source_hash_mismatch', { actualSourceHash: currentHash });
|
||||
}
|
||||
|
||||
if (componentTarget) {
|
||||
return publishComponentArtifact({
|
||||
id,
|
||||
epoch,
|
||||
snapshot,
|
||||
target: componentTarget,
|
||||
artifactManifest,
|
||||
artifactPath,
|
||||
sourcePath,
|
||||
arrivedVariants,
|
||||
expectedVariants,
|
||||
publicationKind,
|
||||
store,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const stablePreview = current;
|
||||
const artifact = fs.readFileSync(artifactPath, 'utf-8');
|
||||
if (!artifact.includes('data-impeccable-variants="' + id + '"')) {
|
||||
return failure('artifact_missing_session_wrapper');
|
||||
}
|
||||
const delivered = countDeliveredVariants(artifact);
|
||||
if (delivered < 1) return failure('artifact_has_no_variants');
|
||||
if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) {
|
||||
return failure('artifact_variant_count_mismatch', { delivered });
|
||||
}
|
||||
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
|
||||
for (let variant = 1; variant <= priorArrived; variant++) {
|
||||
const currentVariant = extractVariantBlock(stablePreview, variant);
|
||||
const artifactVariant = extractVariantBlock(artifact, variant);
|
||||
if (!currentVariant || !artifactVariant) {
|
||||
return failure('published_variant_missing', { variant });
|
||||
}
|
||||
if (sha256(withoutVariantParams(currentVariant)) !== sha256(withoutVariantParams(artifactVariant))) {
|
||||
return failure('published_variant_changed', { variant });
|
||||
}
|
||||
}
|
||||
const currentPreviewCss = extractPreviewCss(stablePreview, id);
|
||||
const artifactPreviewCss = extractPreviewCss(artifact, id);
|
||||
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
|
||||
return failure('published_variant_css_changed');
|
||||
}
|
||||
|
||||
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
|
||||
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
|
||||
if (commitStale) return commitStale;
|
||||
const artifactHash = sha256(artifact);
|
||||
const publishPath = sourcePath;
|
||||
atomicReplace(publishPath, artifact);
|
||||
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
|
||||
store.appendEvent({
|
||||
type: 'variant_published',
|
||||
id,
|
||||
generationEpoch: epoch,
|
||||
revision,
|
||||
digest: artifactHash,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
||||
publicationKind: publicationKind || 'variants',
|
||||
at: Date.now(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch,
|
||||
revision,
|
||||
digest: artifactHash,
|
||||
sourceFile: relative(cwd, sourcePath),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
||||
publicationKind: publicationKind || 'variants',
|
||||
};
|
||||
}, { cwd });
|
||||
} catch (error) {
|
||||
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
|
||||
return failure('publish_failed', { message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function prepareComponentArtifact({
|
||||
id,
|
||||
revision,
|
||||
snapshot,
|
||||
source,
|
||||
sourcePath,
|
||||
requestedPath,
|
||||
target,
|
||||
artifactDir,
|
||||
cwd,
|
||||
}) {
|
||||
const artifactComponentDir = path.join(
|
||||
artifactDir,
|
||||
id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(),
|
||||
);
|
||||
fs.mkdirSync(artifactComponentDir, { recursive: true });
|
||||
copyDirectoryFiles(target.componentPath, artifactComponentDir);
|
||||
const artifactPath = path.join(artifactComponentDir, 'manifest.json');
|
||||
const artifactManifest = {
|
||||
...target.manifest,
|
||||
componentDir: relative(cwd, artifactComponentDir),
|
||||
};
|
||||
fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8');
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch: Number(snapshot.generationEpoch || 1),
|
||||
revision,
|
||||
sourceFile: relative(cwd, requestedPath),
|
||||
targetSourceFile: relative(cwd, sourcePath),
|
||||
artifactFile: relative(cwd, artifactPath),
|
||||
componentDir: relative(cwd, artifactComponentDir),
|
||||
previewMode: target.manifest.previewMode,
|
||||
expectedSourceHash: sha256(source),
|
||||
};
|
||||
}
|
||||
|
||||
function publishComponentArtifact({
|
||||
id,
|
||||
epoch,
|
||||
snapshot,
|
||||
target,
|
||||
artifactManifest,
|
||||
artifactPath,
|
||||
sourcePath,
|
||||
arrivedVariants,
|
||||
expectedVariants,
|
||||
publicationKind,
|
||||
store,
|
||||
cwd,
|
||||
}) {
|
||||
if (!artifactManifest || typeof artifactManifest !== 'object') {
|
||||
return failure('artifact_manifest_invalid');
|
||||
}
|
||||
if (artifactManifest.id !== id || target.manifest.id !== id) {
|
||||
return failure('artifact_session_mismatch');
|
||||
}
|
||||
const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir);
|
||||
if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) {
|
||||
return failure('artifact_component_dir_mismatch');
|
||||
}
|
||||
if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) {
|
||||
return failure('artifact_not_staged');
|
||||
}
|
||||
const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest);
|
||||
if (immutableMismatch) {
|
||||
return failure('artifact_manifest_changed', { field: immutableMismatch });
|
||||
}
|
||||
|
||||
const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0);
|
||||
const declared = optionalPositiveInteger(artifactManifest.arrivedVariants);
|
||||
const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared;
|
||||
if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants');
|
||||
if (expected > 0 && delivered > expected) {
|
||||
return failure('artifact_variant_count_mismatch', { delivered, expected });
|
||||
}
|
||||
if (declared !== null && declared !== delivered) {
|
||||
return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered });
|
||||
}
|
||||
|
||||
const priorArrived = Math.max(
|
||||
optionalPositiveInteger(target.manifest.arrivedVariants) || 0,
|
||||
Number(snapshot.arrivedVariants || 0),
|
||||
);
|
||||
if (delivered < priorArrived) {
|
||||
return failure('artifact_variant_count_regressed', { delivered, priorArrived });
|
||||
}
|
||||
|
||||
const componentExtension = target.manifest.componentExtension
|
||||
|| (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
|
||||
const variantContents = [];
|
||||
for (let variant = 1; variant <= delivered; variant++) {
|
||||
const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension);
|
||||
if (!regularFileInside(artifactComponentPath, artifactVariantPath)) {
|
||||
return failure('artifact_variant_missing', { variant });
|
||||
}
|
||||
const content = fs.readFileSync(artifactVariantPath, 'utf-8');
|
||||
if (!content.trim()) return failure('artifact_variant_empty', { variant });
|
||||
const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension);
|
||||
if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) {
|
||||
return failure('published_variant_missing', { variant });
|
||||
}
|
||||
if (variant <= priorArrived) {
|
||||
const prior = fs.readFileSync(targetVariantPath, 'utf-8');
|
||||
if (sha256(prior) !== sha256(content)) {
|
||||
return failure('published_variant_changed', { variant });
|
||||
}
|
||||
}
|
||||
variantContents.push({ variant, content, targetPath: targetVariantPath });
|
||||
}
|
||||
|
||||
const artifactParamsPath = path.join(artifactComponentPath, 'params.json');
|
||||
let paramsContent = null;
|
||||
if (fs.existsSync(artifactParamsPath)) {
|
||||
if (!regularFileInside(artifactComponentPath, artifactParamsPath)) {
|
||||
return failure('artifact_params_invalid');
|
||||
}
|
||||
paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8');
|
||||
const params = parseJson(paramsContent);
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
||||
return failure('artifact_params_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// Check the fence before writing anything. The prepare→publish gap is exactly
|
||||
// where an Accept lands, and the non-component path above rechecks before
|
||||
// its only write. Without the same check here, a canceled generation still
|
||||
// scattered variant files across the generated component dir and left them
|
||||
// there — the `stale_generation_epoch` returns below have no rollback.
|
||||
const preWriteStale = staleGenerationFailure(store.getSnapshot(id, { includeCompleted: true }), epoch);
|
||||
if (preWriteStale) return preWriteStale;
|
||||
|
||||
// Components and optional params become reachable before the manifest
|
||||
// advertises them. Committing the manifest last makes publication atomic
|
||||
// from the browser's point of view while the source lock excludes Accept.
|
||||
fs.mkdirSync(target.componentPath, { recursive: true });
|
||||
for (const variant of variantContents) {
|
||||
if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content);
|
||||
}
|
||||
if (paramsContent !== null) {
|
||||
atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent);
|
||||
}
|
||||
// Re-check immediately before the manifest: the manifest is what makes the
|
||||
// variants visible to the browser, so this is the gate that actually matters.
|
||||
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
|
||||
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
|
||||
if (commitStale) return commitStale;
|
||||
const publishedManifest = {
|
||||
...target.manifest,
|
||||
componentDir: relative(cwd, target.componentPath),
|
||||
arrivedVariants: delivered,
|
||||
};
|
||||
delete publishedManifest.manifestPath;
|
||||
const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n';
|
||||
atomicReplace(target.manifestPath, manifestContent);
|
||||
|
||||
const digest = digestComponentPublication(manifestContent, variantContents, paramsContent);
|
||||
const revision = Number(snapshot.publishedRevision || 0) + 1;
|
||||
const sourceFile = relative(cwd, sourcePath);
|
||||
const previewFile = relative(cwd, target.manifestPath);
|
||||
store.appendEvent({
|
||||
type: 'variant_published',
|
||||
id,
|
||||
generationEpoch: epoch,
|
||||
revision,
|
||||
digest,
|
||||
sourceFile,
|
||||
previewFile,
|
||||
previewMode: target.manifest.previewMode,
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: expected || delivered,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
at: Date.now(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
epoch,
|
||||
revision,
|
||||
digest,
|
||||
sourceFile,
|
||||
previewFile,
|
||||
previewMode: target.manifest.previewMode,
|
||||
componentDir: relative(cwd, target.componentPath),
|
||||
arrivedVariants: delivered,
|
||||
expectedVariants: expected || delivered,
|
||||
publicationKind: publicationKind || 'variants',
|
||||
};
|
||||
}
|
||||
|
||||
const COMPONENT_MANIFEST_FIELDS = [
|
||||
'id',
|
||||
'mode',
|
||||
'previewMode',
|
||||
'sourceFile',
|
||||
'sourceStartLine',
|
||||
'sourceEndLine',
|
||||
'insertLine',
|
||||
'position',
|
||||
'anchorStartLine',
|
||||
'anchorEndLine',
|
||||
'count',
|
||||
'propContract',
|
||||
'originalMarkup',
|
||||
'anchorMarkup',
|
||||
'runtimeModule',
|
||||
'componentModuleBase',
|
||||
'framework',
|
||||
'componentExtension',
|
||||
];
|
||||
|
||||
function readComponentPublicationTarget(manifestPath, cwd, id) {
|
||||
if (path.basename(manifestPath) !== 'manifest.json') return null;
|
||||
const manifest = readJson(manifestPath);
|
||||
if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null;
|
||||
if (manifest.id !== id) return failure('artifact_session_mismatch');
|
||||
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
||||
const componentPath = resolveInside(cwd, manifest.componentDir);
|
||||
if (!sourcePath || !componentPath) return failure('path_outside_project');
|
||||
if (!fs.existsSync(sourcePath)) return failure('source_missing');
|
||||
if (path.resolve(componentPath) !== path.dirname(manifestPath)) {
|
||||
return failure('manifest_component_dir_mismatch');
|
||||
}
|
||||
return { manifest, manifestPath, sourcePath, componentPath };
|
||||
}
|
||||
|
||||
function componentManifestMismatch(target, artifact) {
|
||||
for (const field of COMPONENT_MANIFEST_FIELDS) {
|
||||
if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isComponentPreviewMode(value) {
|
||||
return value === 'svelte-component' || value === 'vue-component';
|
||||
}
|
||||
|
||||
function copyDirectoryFiles(sourceDir, targetDir) {
|
||||
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
||||
fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
function regularFileInside(root, file) {
|
||||
const rel = path.relative(root, file);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
try {
|
||||
return fs.lstatSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDescendant(root, candidate) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function digestComponentPublication(manifestContent, variants, paramsContent) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(manifestContent);
|
||||
for (const variant of variants) {
|
||||
hash.update('\0v' + variant.variant + '\0');
|
||||
hash.update(variant.content);
|
||||
}
|
||||
if (paramsContent !== null) hash.update('\0params\0' + paramsContent);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(value) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function countDeliveredVariants(source) {
|
||||
const matches = source.match(/<div\b[^>]*\bdata-impeccable-variant=(?:"|')(?!original(?:"|'))[^"']+(?:"|')[^>]*>/g);
|
||||
return matches?.length || 0;
|
||||
}
|
||||
|
||||
function extractVariantBlock(source, variant) {
|
||||
const open = /<div\b[^>]*>/gi;
|
||||
let match;
|
||||
let start = -1;
|
||||
const attr = new RegExp("\\bdata-impeccable-variant=(?:\"" + variant + "\"|'" + variant + "')");
|
||||
while ((match = open.exec(source))) {
|
||||
if (attr.test(match[0])) {
|
||||
start = match.index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start < 0) return null;
|
||||
|
||||
const token = /<div\b[^>]*\/\s*>|<div\b[^>]*>|<\/div\s*>/gi;
|
||||
token.lastIndex = start;
|
||||
let depth = 0;
|
||||
while ((match = token.exec(source))) {
|
||||
if (/^<\/div/i.test(match[0])) {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, token.lastIndex);
|
||||
} else if (!/\/\s*>$/.test(match[0])) {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withoutVariantParams(block) {
|
||||
return String(block || '').replace(
|
||||
/\sdata-impeccable-params=(?:"[^"]*"|'[^']*')/i,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function extractPreviewCss(source, id) {
|
||||
const escapedId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const open = new RegExp("<style\\b[^>]*\\bdata-impeccable-css=(?:\"" + escapedId + "\"|'" + escapedId + "')[^>]*>", 'i');
|
||||
const match = open.exec(source);
|
||||
if (!match) return '';
|
||||
const start = match.index + match[0].length;
|
||||
const end = source.indexOf('</style>', start);
|
||||
if (end < 0) return '';
|
||||
return source.slice(start, end)
|
||||
.replace(/^\s*\{\s*`\s*/, '')
|
||||
.replace(/\s*`\s*\}\s*$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function atomicReplace(target, content) {
|
||||
let mode = 0o666;
|
||||
try { mode = fs.statSync(target).mode; } catch {}
|
||||
const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
|
||||
try {
|
||||
fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
|
||||
fs.renameSync(temp, target);
|
||||
} finally {
|
||||
try { fs.unlinkSync(temp); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInside(cwd, value) {
|
||||
const resolved = path.resolve(cwd, value);
|
||||
const rel = path.relative(cwd, resolved);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function relative(cwd, value) {
|
||||
return path.relative(cwd, value).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function failure(error, details = {}) {
|
||||
return { ok: false, error, ...details };
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation fence: has this session been canceled (Accept/Discard landed),
|
||||
* or has a newer generation superseded this epoch? Returns a failure result to
|
||||
* propagate, or null when the caller may proceed.
|
||||
*/
|
||||
function staleGenerationFailure(snapshot, epoch) {
|
||||
if (snapshot?.generationCanceled === true) {
|
||||
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
|
||||
}
|
||||
if (Number(snapshot?.generationEpoch || 1) !== epoch) {
|
||||
return failure('stale_generation_epoch', { expectedEpoch: snapshot?.generationEpoch || 1 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function eventPriority(event = {}) {
|
||||
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
|
||||
if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
|
||||
if (event.type === 'generate') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
|
||||
const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
|
||||
return entries
|
||||
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
|
||||
.filter((entry) => !allowed || allowed.has(entry.event?.type))
|
||||
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
const GENERATION_FENCED_PHASES = new Set([
|
||||
'accept_requested',
|
||||
'discard_requested',
|
||||
'carbonize_required',
|
||||
'completed',
|
||||
'discarded',
|
||||
]);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
// No snapshot cache on purpose: appendEvent and getSnapshot both rebuild from
|
||||
// the journal so sequence numbers and phase fences never come from a stale
|
||||
// in-memory copy when the publisher/complete helpers append from another
|
||||
// process. A cache written but never read would grow per session for the
|
||||
// lifetime of the server without ever saving a rebuild.
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
@@ -38,7 +40,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
// Publisher/complete helpers can append from a separate process while
|
||||
// the server is alive. Rebuild here so sequence numbers and phase
|
||||
// fences never come from a stale in-memory cache.
|
||||
const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
@@ -49,7 +54,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
@@ -58,7 +62,6 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
@@ -95,11 +98,6 @@ function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
@@ -116,9 +114,21 @@ function baseSnapshot(id) {
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
browserCheckpointRevision: 0,
|
||||
publicationCheckpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
generationPhase: null,
|
||||
generationTimings: {},
|
||||
generationEpoch: 1,
|
||||
publishedRevision: 0,
|
||||
deliveredVariants: {},
|
||||
variantPlan: null,
|
||||
paramsPublished: false,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
@@ -158,6 +168,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
@@ -170,14 +183,81 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
next.variantPlan = null;
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variant_plan':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.variantPlan = event.plan ?? next.variantPlan;
|
||||
}
|
||||
break;
|
||||
case 'detector_waivers':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.detectorWaivers = [
|
||||
...(next.detectorWaivers || []),
|
||||
...(Array.isArray(event.waivers) ? event.waivers : []),
|
||||
];
|
||||
}
|
||||
break;
|
||||
case 'variant_published':
|
||||
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({
|
||||
error: 'late_generation_event_ignored',
|
||||
type: event.type,
|
||||
phase: next.phase,
|
||||
revision: event.revision ?? null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
|
||||
next.diagnostics.push({
|
||||
error: 'stale_generation_epoch_ignored',
|
||||
epoch: event.generationEpoch ?? null,
|
||||
expectedEpoch: next.generationEpoch || 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
next.phase = 'variants_progress';
|
||||
next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
|
||||
next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
|
||||
next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
|
||||
if (event.publicationKind === 'params') next.paramsPublished = true;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.revision) {
|
||||
next.deliveredVariants[String(event.revision)] = {
|
||||
digest: event.digest || null,
|
||||
arrivedVariants: Number(event.arrivedVariants || 0),
|
||||
publishedAt: event.at || null,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'agent_phase':
|
||||
next.generationPhase = event.phase ?? next.generationPhase;
|
||||
if (event.phase) {
|
||||
next.generationTimings[event.phase] = {
|
||||
at: event.at ?? (Date.parse(entry.ts || '') || null),
|
||||
durationMs: event.durationMs ?? null,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
|
||||
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
|
||||
next.diagnostics.push({
|
||||
error: 'late_generation_event_ignored',
|
||||
type: event.type,
|
||||
phase: next.phase,
|
||||
});
|
||||
break;
|
||||
}
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
@@ -194,27 +274,45 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if (COMPLETED_PHASES.has(next.phase)) {
|
||||
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
|
||||
break;
|
||||
}
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
{
|
||||
const revisionDomain = event.revisionDomain === 'publication'
|
||||
|| (event.reason === 'variants_progress' && !event.owner)
|
||||
? 'publication'
|
||||
: 'browser';
|
||||
const revisionField = revisionDomain === 'publication'
|
||||
? 'publicationCheckpointRevision'
|
||||
: 'browserCheckpointRevision';
|
||||
const currentRevision = next[revisionField]
|
||||
?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
|
||||
?? 0;
|
||||
if ((event.revision ?? 0) >= currentRevision) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next[revisionField] = event.revision ?? currentRevision;
|
||||
if (revisionDomain === 'browser') {
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
}
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.generationCanceled = true;
|
||||
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
|
||||
next.cancelReason = 'accept';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
@@ -232,6 +330,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'carbonize_cleanup':
|
||||
next.phase = 'carbonize_cleanup_requested';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'steer_done':
|
||||
next.phase = 'steer_done';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
@@ -243,6 +347,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.generationCanceled = true;
|
||||
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
|
||||
next.cancelReason = 'discard';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
@@ -260,6 +367,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
if (next.generationCanceled && event.sourceEventType === 'generate') {
|
||||
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
|
||||
break;
|
||||
}
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { getLiveDir, isLiveServerPidReachable } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
// Only used to retire a lock whose contents we cannot read (empty or truncated
|
||||
// by a crash mid-write). A readable lock's fate is decided by its owner's
|
||||
// liveness instead, so a slow critical section is never swept.
|
||||
const UNREADABLE_LOCK_STALE_MS = 60_000;
|
||||
|
||||
export function sourceLockPath(file, cwd = process.cwd()) {
|
||||
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
|
||||
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
|
||||
}
|
||||
|
||||
export function withSourceLockSync(file, owner, fn, {
|
||||
cwd = process.cwd(),
|
||||
waitMs = 0,
|
||||
retryMs = 5,
|
||||
} = {}) {
|
||||
const lockPath = sourceLockPath(file, cwd);
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
||||
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
|
||||
// Identifies this acquisition specifically, so release can tell our own lock
|
||||
// from a replacement that some other writer created.
|
||||
const token = randomUUID();
|
||||
let acquired = false;
|
||||
|
||||
while (!acquired) {
|
||||
clearStaleLock(lockPath);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeFileSync(fd, JSON.stringify({
|
||||
owner,
|
||||
token,
|
||||
pid: process.pid,
|
||||
at: Date.now(),
|
||||
file: path.resolve(cwd, file),
|
||||
}) + '\n');
|
||||
acquired = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
if (Date.now() >= deadline) {
|
||||
const locked = new Error('source_locked');
|
||||
locked.code = 'SOURCE_LOCKED';
|
||||
locked.lockPath = lockPath;
|
||||
throw locked;
|
||||
}
|
||||
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
|
||||
} finally {
|
||||
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
releaseOwnLock(lockPath, token);
|
||||
}
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function readLock(lockPath) {
|
||||
try { return JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the lock only if it is still the one this call created. If a sweeper
|
||||
* judged our lock stale and another writer replaced it, unlinking here would
|
||||
* end *their* critical section and admit a third writer to the same file.
|
||||
*/
|
||||
function releaseOwnLock(lockPath, token) {
|
||||
const held = readLock(lockPath);
|
||||
if (held && held.token !== token) return;
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lock is stale when its owner is gone, not when it is old.
|
||||
*
|
||||
* Age alone cuts both ways: it sweeps a live holder whose critical section
|
||||
* outran the timeout (a suspended laptop, a stopped process), letting two
|
||||
* writers into the same source file, while still making every accept on a
|
||||
* crashed holder's file wait out the full timeout. Asking the OS whether the
|
||||
* recorded pid is alive answers both correctly: a dead owner releases at once,
|
||||
* and a live owner keeps its lock however long it needs.
|
||||
*/
|
||||
function clearStaleLock(lockPath) {
|
||||
const held = readLock(lockPath);
|
||||
if (!held) {
|
||||
// Unreadable: either a crash truncated it, or we caught the brief window
|
||||
// between create and write in a live acquisition. mtime distinguishes them.
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > UNREADABLE_LOCK_STALE_MS) fs.unlinkSync(lockPath);
|
||||
} catch { /* gone already */ }
|
||||
return;
|
||||
}
|
||||
if (typeof held.pid === 'number' && isLiveServerPidReachable(held.pid)) return;
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Nuxt/Vue live-mode component previews.
|
||||
*
|
||||
* Generation writes real Vue SFCs into a generated app-local module tree.
|
||||
* Nuxt/Vite compiles those modules without touching the active route; Accept
|
||||
* is the only operation that writes the user's .vue source.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectNuxtVueProject(cwd = process.cwd()) {
|
||||
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
|
||||
if (!configFile) return null;
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
|
||||
if (srcDirMatch) {
|
||||
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
|
||||
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
|
||||
appDir = candidate === '.' ? '' : candidate;
|
||||
}
|
||||
}
|
||||
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
|
||||
return { configFile, appDir, componentRoot };
|
||||
}
|
||||
|
||||
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
|
||||
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
|
||||
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
|
||||
}
|
||||
|
||||
export function vueComponentSessionDir(id, cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) throw new Error('Nuxt project not found');
|
||||
return path.join(cwd, project.componentRoot, safeSessionId(id));
|
||||
}
|
||||
|
||||
export function vueManifestPathForSession(id, cwd = process.cwd()) {
|
||||
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
|
||||
}
|
||||
|
||||
function ensureVueRuntime(cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) throw new Error('Nuxt project not found');
|
||||
const rel = `${project.componentRoot}/__runtime.js`;
|
||||
const file = path.join(cwd, rel);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
|
||||
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
|
||||
return nuxtViteFsModulePath(file, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
|
||||
* Keep the manifest path base-agnostic and let the browser prepend the
|
||||
* runtime's actual buildAssetsDir. A page-route URL such as
|
||||
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
|
||||
*/
|
||||
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
|
||||
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
|
||||
const relative = path.relative(cwd, absolute);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error('Nuxt live module must stay inside the project root');
|
||||
}
|
||||
return '/@fs/' + absolute.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
export function extractVueExpressions(markup) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||
let match;
|
||||
while ((match = re.exec(String(markup || '')))) {
|
||||
const expr = match[1].trim();
|
||||
if (!expr || seen.has(expr)) continue;
|
||||
seen.add(expr);
|
||||
out.push({ expr, token: match[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildVuePropContract(expressions) {
|
||||
return expressions.map(({ expr, token }, index) => ({
|
||||
prop: derivePropName(expr, index),
|
||||
expr,
|
||||
placeholder: token,
|
||||
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
|
||||
// the inner `{ user.name }` token; preserve its whitespace for the
|
||||
// browser's source-text → rendered-text map.
|
||||
previewToken: token.slice(1, -1),
|
||||
}));
|
||||
}
|
||||
|
||||
function derivePropName(expr, index) {
|
||||
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
|
||||
return tail?.[1] || `prop${index}`;
|
||||
}
|
||||
|
||||
function substituteVueExpressions(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildVueVariantStub(variant, markup, contract) {
|
||||
const props = contract.length > 0
|
||||
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
|
||||
: '';
|
||||
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
|
||||
}
|
||||
|
||||
export function scaffoldVueComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
originalLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
const runtimeModule = ensureVueRuntime(cwd);
|
||||
const dir = vueComponentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
|
||||
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'vue-component',
|
||||
framework: 'vue',
|
||||
componentExtension: 'vue',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
count,
|
||||
propContract,
|
||||
originalMarkup,
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
|
||||
runtimeModule,
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
for (let variant = 1; variant <= count; variant++) {
|
||||
const file = path.join(dir, `v${variant}.vue`);
|
||||
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract,
|
||||
};
|
||||
}
|
||||
|
||||
export function findVueComponentManifest(id, cwd = process.cwd()) {
|
||||
let direct;
|
||||
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
|
||||
if (!fs.existsSync(direct)) return null;
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
|
||||
return manifest?.id === id && manifest?.previewMode === 'vue-component'
|
||||
? { ...manifest, manifestPath: direct }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseVueSfc(source) {
|
||||
const text = String(source || '');
|
||||
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
|
||||
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
|
||||
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
|
||||
}
|
||||
|
||||
function restoreVueExpressions(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract || []) {
|
||||
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
|
||||
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
||||
const componentDir = resolveInside(cwd, manifest.componentDir);
|
||||
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
|
||||
const resultBase = {
|
||||
file: manifest.sourceFile,
|
||||
sourceFile: manifest.sourceFile,
|
||||
previewMode: 'vue-component',
|
||||
componentDir: manifest.componentDir,
|
||||
carbonize: false,
|
||||
};
|
||||
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
|
||||
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
|
||||
}
|
||||
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
|
||||
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
|
||||
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
|
||||
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
|
||||
}
|
||||
|
||||
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n');
|
||||
const start = Number(manifest.sourceStartLine) - 1;
|
||||
const end = Number(manifest.sourceEndLine) - 1;
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
||||
const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
|
||||
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
|
||||
.split('\n')
|
||||
.map((line) => line.trim() ? indent + line.trimStart() : '');
|
||||
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
|
||||
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
|
||||
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
|
||||
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
|
||||
retireVueComponentSession(manifest.id, cwd);
|
||||
return { handled: true, ...resultBase };
|
||||
}
|
||||
|
||||
function appendVueStyle(lines, cssLines) {
|
||||
let close = -1;
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
|
||||
}
|
||||
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
|
||||
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
|
||||
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
|
||||
}
|
||||
|
||||
function mergeOriginalVueAttrs(markup, originalMarkup) {
|
||||
const variant = matchOpeningTag(markup);
|
||||
const original = matchOpeningTag(originalMarkup);
|
||||
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
|
||||
const variantAttrs = parseStaticAttrs(variant.attrs);
|
||||
const originalAttrs = parseStaticAttrs(original.attrs);
|
||||
const additions = [];
|
||||
let attrs = variant.attrs;
|
||||
|
||||
const originalClass = originalAttrs.get('class');
|
||||
const variantClass = variantAttrs.get('class');
|
||||
if (originalClass && variantClass) {
|
||||
const classes = [
|
||||
...variantClass.value.split(/\s+/),
|
||||
...originalClass.value.split(/\s+/),
|
||||
].filter(Boolean);
|
||||
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
|
||||
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
|
||||
} else if (originalClass) {
|
||||
additions.push(originalClass.raw);
|
||||
}
|
||||
for (const [name, attr] of originalAttrs) {
|
||||
if (name === 'class' || variantAttrs.has(name)) continue;
|
||||
additions.push(attr.raw);
|
||||
}
|
||||
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
|
||||
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
|
||||
}
|
||||
|
||||
function matchOpeningTag(markup) {
|
||||
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
|
||||
return match ? {
|
||||
raw: match[0],
|
||||
tag: match[1],
|
||||
attrs: match[2] || '',
|
||||
close: match[3],
|
||||
index: match.index || 0,
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the attributes of a Vue opening tag.
|
||||
*
|
||||
* The name pattern is deliberately permissive so directive shorthands survive
|
||||
* a round trip: `@click.prevent`, `:aria-label`, `:[dynamicKey]`, `#default`,
|
||||
* and `v-cloak` are all one attribute each. A name-anchored pattern such as
|
||||
* `[A-Za-z_:][\w:.-]*` skips the `@`/`#` sigil and re-matches from the bare
|
||||
* name, which turns `@click="submit"` into a literal `click="submit"` DOM
|
||||
* attribute on Accept. Values are optional so valueless attributes
|
||||
* (`disabled`, `v-cloak`) are recorded rather than dropped.
|
||||
*/
|
||||
function parseStaticAttrs(attrs) {
|
||||
const out = new Map();
|
||||
const re = /([^\s"'=<>/]+)(?:\s*=\s*(?:(["'])([\s\S]*?)\2|([^\s"'=<>`]+)))?/g;
|
||||
let match;
|
||||
while ((match = re.exec(attrs))) {
|
||||
const quoted = match[2] !== undefined;
|
||||
const valueless = !quoted && match[4] === undefined;
|
||||
out.set(normalizeVueAttrName(match[1]), {
|
||||
raw: match[0],
|
||||
value: valueless ? '' : (quoted ? match[3] : match[4]),
|
||||
quote: quoted ? match[2] : '"',
|
||||
valueless,
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse Vue's directive shorthands to their canonical form for identity
|
||||
* comparison only (the raw text is what gets written back). Without this, an
|
||||
* original `:aria-label` and a variant `v-bind:aria-label` read as two
|
||||
* different attributes and Accept emits both, which is a Vue compile error.
|
||||
*/
|
||||
function normalizeVueAttrName(name) {
|
||||
const raw = String(name);
|
||||
if (raw.startsWith('@')) return `v-on:${raw.slice(1)}`;
|
||||
if (raw.startsWith(':')) return `v-bind:${raw.slice(1)}`;
|
||||
if (raw.startsWith('#')) return `v-slot:${raw.slice(1)}`;
|
||||
if (raw.startsWith('.')) return `v-bind:${raw.slice(1)}.prop`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function removeVueComponentSession(id, cwd = process.cwd()) {
|
||||
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an accepted/discarded session undiscoverable immediately while keeping
|
||||
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
|
||||
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
|
||||
* missing module and emit a console error. The generated directory remains
|
||||
* ignored and removeAllVueComponentSessions removes it on server shutdown.
|
||||
*/
|
||||
export function retireVueComponentSession(id, cwd = process.cwd()) {
|
||||
let dir;
|
||||
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
|
||||
for (const name of ['manifest.json', 'params.json']) {
|
||||
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function removeAllVueComponentSessions(cwd = process.cwd()) {
|
||||
const project = detectNuxtVueProject(cwd);
|
||||
if (!project) return;
|
||||
const root = path.join(cwd, project.componentRoot);
|
||||
if (!fs.existsSync(root)) return;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function buildVueComponentCssAuthoring(count) {
|
||||
return {
|
||||
mode: 'vue-component',
|
||||
count,
|
||||
requirements: [
|
||||
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
|
||||
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
|
||||
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
|
||||
'Do not add data-impeccable-* attributes.',
|
||||
],
|
||||
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInside(cwd, value) {
|
||||
if (!value || path.isAbsolute(value)) return null;
|
||||
const full = path.resolve(cwd, value);
|
||||
const rel = path.relative(cwd, full);
|
||||
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for scripts/lib/cli-args.mjs — the shared argv parser for the Live
|
||||
* benchmark / judging scripts.
|
||||
* Run with: node --test tests/cli-args.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs';
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('reads space-separated values', () => {
|
||||
// The regression: without the argv[i+1] lookahead this yielded
|
||||
// {fixture: true, iterations: true}, silently benchmarking the defaults.
|
||||
assert.deepEqual(
|
||||
parseArgs(['--fixture', 'vite8-react-modal', '--iterations', '20']),
|
||||
{ fixture: 'vite8-react-modal', iterations: '20' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reads --flag=value values', () => {
|
||||
assert.deepEqual(parseArgs(['--fixture=vite8-react-plain']), { fixture: 'vite8-react-plain' });
|
||||
});
|
||||
|
||||
it('treats a flag followed by another flag as boolean', () => {
|
||||
assert.deepEqual(parseArgs(['--headed', '--quiet']), { headed: true, quiet: true });
|
||||
});
|
||||
|
||||
it('treats a trailing flag as boolean', () => {
|
||||
assert.deepEqual(parseArgs(['--append']), { append: true });
|
||||
});
|
||||
|
||||
it('camel-cases kebab keys so both spellings land on one key', () => {
|
||||
assert.deepEqual(parseArgs(['--simulated-tail-ms=250']), { simulatedTailMs: '250' });
|
||||
assert.deepEqual(parseArgs(['--simulatedTailMs=250']), { simulatedTailMs: '250' });
|
||||
assert.deepEqual(parseArgs(['--median-target', '0.4']), { medianTarget: '0.4' });
|
||||
});
|
||||
|
||||
it('keeps a value that contains an equals sign intact', () => {
|
||||
assert.deepEqual(parseArgs(['--model=claude-sonnet-4-6=x']), { model: 'claude-sonnet-4-6=x' });
|
||||
});
|
||||
|
||||
it('ignores positional args and a bare --', () => {
|
||||
assert.deepEqual(parseArgs(['positional', '--', '--real', 'v']), { real: 'v' });
|
||||
});
|
||||
|
||||
it('lets a later occurrence win', () => {
|
||||
assert.deepEqual(parseArgs(['--agent', 'fake', '--agent', 'llm']), { agent: 'llm' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCamel', () => {
|
||||
it('upcases after hyphens only', () => {
|
||||
assert.equal(toCamel('simulated-tail-ms'), 'simulatedTailMs');
|
||||
assert.equal(toCamel('p95-target'), 'p95Target');
|
||||
assert.equal(toCamel('already'), 'already');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolFlag', () => {
|
||||
it('accepts the bare-flag sentinel and the explicit spellings alike', () => {
|
||||
// --headed and --headed=true must not diverge.
|
||||
assert.equal(boolFlag(true), true);
|
||||
assert.equal(boolFlag('true'), true);
|
||||
assert.equal(boolFlag('1'), true);
|
||||
assert.equal(boolFlag('yes'), true);
|
||||
assert.equal(boolFlag(''), true);
|
||||
});
|
||||
|
||||
it('recognizes negative spellings', () => {
|
||||
assert.equal(boolFlag('false'), false);
|
||||
assert.equal(boolFlag('0'), false);
|
||||
assert.equal(boolFlag('no'), false);
|
||||
});
|
||||
|
||||
it('falls back when absent or unrecognized', () => {
|
||||
assert.equal(boolFlag(undefined), false);
|
||||
assert.equal(boolFlag(undefined, true), true);
|
||||
assert.equal(boolFlag('maybe', true), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positiveIntFlag', () => {
|
||||
it('parses positive integers', () => {
|
||||
assert.equal(positiveIntFlag('20', 5), 20);
|
||||
});
|
||||
|
||||
it('falls back when absent or given as a bare flag', () => {
|
||||
assert.equal(positiveIntFlag(undefined, 5), 5);
|
||||
assert.equal(positiveIntFlag(true, 5), 5);
|
||||
});
|
||||
|
||||
it('throws rather than silently using the default', () => {
|
||||
// Quietly benchmarking 5 iterations when 20 were asked for is the failure
|
||||
// this replaces.
|
||||
for (const bad of ['abc', '0', '-3', '2.5', '20x']) {
|
||||
assert.throws(() => positiveIntFlag(bad, 5), /positive integer/, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEnum', () => {
|
||||
it('accepts an allowed value, case-insensitively', () => {
|
||||
assert.equal(resolveEnum('llm', ['fake', 'llm'], 'fake', '--agent'), 'llm');
|
||||
assert.equal(resolveEnum('LLM', ['fake', 'llm'], 'fake', '--agent'), 'llm');
|
||||
});
|
||||
|
||||
it('falls back when absent or given as a bare flag', () => {
|
||||
assert.equal(resolveEnum(undefined, ['fake', 'llm'], 'fake', '--agent'), 'fake');
|
||||
assert.equal(resolveEnum(true, ['fake', 'llm'], 'fake', '--agent'), 'fake');
|
||||
});
|
||||
|
||||
it('throws on an unrecognized value instead of silently using the default', () => {
|
||||
// The private evals Live runner passes --agent=codex. Falling back to the
|
||||
// canned fake agent produced a clean report of a deterministic stub labelled
|
||||
// as a real harness run.
|
||||
assert.throws(
|
||||
() => resolveEnum('codex', ['fake', 'llm'], 'fake', '--agent'),
|
||||
/--agent must be one of fake, llm; got: codex/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveEnum('progresive', ['atomic', 'progressive'], 'atomic', '--delivery'),
|
||||
/--delivery must be one of atomic, progressive/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
@@ -17,6 +18,60 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
|
||||
|
||||
describe('detectText - Astro structural CSS fixtures', () => {
|
||||
const SHOULD_FLAG = [
|
||||
'Kinpaku Edge',
|
||||
'Patina Edge',
|
||||
'Accent Edge',
|
||||
'Signal Blue Edge',
|
||||
'Chromatic Hex Edge',
|
||||
'Named Red Edge',
|
||||
'Chromatic Rgb Edge',
|
||||
'Chromatic Oklch Edge',
|
||||
// `inset` may follow the offsets/color. Requiring it first missed the same
|
||||
// stripe written the other legal way.
|
||||
'Trailing Inset Edge',
|
||||
'Trailing Inset Token Edge',
|
||||
'Inset Named Token Edge',
|
||||
];
|
||||
const SHOULD_PASS = [
|
||||
'Neutral Shadow Token',
|
||||
'Current Color Edge',
|
||||
'Selected State Edge',
|
||||
'Hairline Edge',
|
||||
'Thick Fill Edge',
|
||||
'Blurred Edge',
|
||||
'Narrow Artwork',
|
||||
// Authored CSS spells neutrals as hex and keywords. isNeutralColor only
|
||||
// parses the computed function forms and reports everything else as
|
||||
// chromatic, so routing these through it flagged plain black and gray
|
||||
// hairlines as the "colored stripe" AI tell.
|
||||
'Black Hex Edge',
|
||||
'Black Named Edge',
|
||||
'Gray Hex Edge',
|
||||
'Dimgray Named Edge',
|
||||
'Black Rgb Edge',
|
||||
'Shorthand Neutral Hex Edge',
|
||||
// Commented-out CSS is not a live rule.
|
||||
'Commented Out Edge',
|
||||
// Trailing `inset` still respects the neutral-color exemption.
|
||||
'Trailing Inset Neutral Edge',
|
||||
];
|
||||
|
||||
it('Astro style blocks flag unresolved chromatic inset stripes only', () => {
|
||||
const filePath = path.join(FIXTURES, 'astro-inset-shadow-stripe.astro');
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||||
for (const heading of SHOULD_FLAG) {
|
||||
assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`);
|
||||
}
|
||||
for (const heading of SHOULD_PASS) {
|
||||
assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
it('should-flag: catches border anti-patterns', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
const title = 'Astro inset shadow stripe regression';
|
||||
---
|
||||
|
||||
<main>
|
||||
<h1>{title}</h1>
|
||||
<section aria-labelledby="should-flag">
|
||||
<h2 id="should-flag">Should flag</h2>
|
||||
<article data-case="Kinpaku Edge"><h3>Kinpaku Edge</h3></article>
|
||||
<article data-case="Patina Edge"><h3>Patina Edge</h3></article>
|
||||
<article data-case="Accent Edge"><h3>Accent Edge</h3></article>
|
||||
<article data-case="Signal Blue Edge"><h3>Signal Blue Edge</h3></article>
|
||||
<article data-case="Chromatic Hex Edge"><h3>Chromatic Hex Edge</h3></article>
|
||||
<article data-case="Named Red Edge"><h3>Named Red Edge</h3></article>
|
||||
<article data-case="Chromatic Rgb Edge"><h3>Chromatic Rgb Edge</h3></article>
|
||||
<article data-case="Chromatic Oklch Edge"><h3>Chromatic Oklch Edge</h3></article>
|
||||
<article data-case="Trailing Inset Edge"><h3>Trailing Inset Edge</h3></article>
|
||||
<article data-case="Trailing Inset Token Edge"><h3>Trailing Inset Token Edge</h3></article>
|
||||
<article data-case="Inset Named Token Edge"><h3>Inset Named Token Edge</h3></article>
|
||||
</section>
|
||||
<section aria-labelledby="should-pass">
|
||||
<h2 id="should-pass">Should pass</h2>
|
||||
<article data-case="Neutral Shadow Token"><h3>Neutral Shadow Token</h3></article>
|
||||
<article data-case="Current Color Edge"><h3>Current Color Edge</h3></article>
|
||||
<article data-case="Selected State Edge" aria-current="page"><h3>Selected State Edge</h3></article>
|
||||
<article data-case="Hairline Edge"><h3>Hairline Edge</h3></article>
|
||||
<article data-case="Thick Fill Edge"><h3>Thick Fill Edge</h3></article>
|
||||
<article data-case="Blurred Edge"><h3>Blurred Edge</h3></article>
|
||||
<article data-case="Narrow Artwork"><h3>Narrow Artwork</h3></article>
|
||||
<article data-case="Black Hex Edge"><h3>Black Hex Edge</h3></article>
|
||||
<article data-case="Black Named Edge"><h3>Black Named Edge</h3></article>
|
||||
<article data-case="Gray Hex Edge"><h3>Gray Hex Edge</h3></article>
|
||||
<article data-case="Dimgray Named Edge"><h3>Dimgray Named Edge</h3></article>
|
||||
<article data-case="Black Rgb Edge"><h3>Black Rgb Edge</h3></article>
|
||||
<article data-case="Shorthand Neutral Hex Edge"><h3>Shorthand Neutral Hex Edge</h3></article>
|
||||
<article data-case="Commented Out Edge"><h3>Commented Out Edge</h3></article>
|
||||
<article data-case="Trailing Inset Neutral Edge"><h3>Trailing Inset Neutral Edge</h3></article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style is:inline>
|
||||
[data-case="Kinpaku Edge"] { box-shadow: inset 3px 0 0 var(--ks-kinpaku-deep); }
|
||||
[data-case="Patina Edge"] { box-shadow: inset 3px 0 0 var(--ks-patina-deep); }
|
||||
[data-case="Accent Edge"] { box-shadow: inset -4px 0 0 var(--brand-accent); }
|
||||
[data-case="Signal Blue Edge"] { box-shadow: inset 0 5px 0 var(--signal-blue); }
|
||||
[data-case="Neutral Shadow Token"] { box-shadow: inset 3px 0 0 var(--shadow-color); }
|
||||
[data-case="Current Color Edge"] { box-shadow: inset 3px 0 0 currentColor; }
|
||||
[data-case="Selected State Edge"][aria-current="page"] { box-shadow: inset 3px 0 0 var(--brand-accent); }
|
||||
[data-case="Hairline Edge"] { box-shadow: inset 2px 0 0 var(--brand-accent); }
|
||||
[data-case="Thick Fill Edge"] { box-shadow: inset 14px 0 0 var(--brand-accent); }
|
||||
[data-case="Blurred Edge"] { box-shadow: inset 3px 0 5px var(--brand-accent); }
|
||||
[data-case="Narrow Artwork"] { width: 24px; box-shadow: inset 3px 0 0 var(--brand-accent); }
|
||||
|
||||
/* Literal colors: authored CSS spells neutrals as hex and keywords, not as
|
||||
the computed rgb()/oklch() forms a browser emits. */
|
||||
[data-case="Chromatic Hex Edge"] { box-shadow: inset 4px 0 0 #6366f1; }
|
||||
[data-case="Named Red Edge"] { box-shadow: inset 4px 0 0 red; }
|
||||
[data-case="Chromatic Rgb Edge"] { box-shadow: inset 4px 0 0 rgb(99, 102, 241); }
|
||||
[data-case="Chromatic Oklch Edge"] { box-shadow: inset 4px 0 0 oklch(65% 0.18 250); }
|
||||
[data-case="Black Hex Edge"] { box-shadow: inset 4px 0 0 #000; }
|
||||
[data-case="Black Named Edge"] { box-shadow: inset 4px 0 0 black; }
|
||||
[data-case="Gray Hex Edge"] { box-shadow: inset 4px 0 0 #e5e7eb; }
|
||||
[data-case="Dimgray Named Edge"] { box-shadow: inset 4px 0 0 dimgray; }
|
||||
[data-case="Black Rgb Edge"] { box-shadow: inset 4px 0 0 rgb(0, 0, 0); }
|
||||
[data-case="Shorthand Neutral Hex Edge"] { box-shadow: inset 4px 0 0 #1118; }
|
||||
|
||||
/* `inset` is order-independent per spec; these paint the same stripe as above. */
|
||||
[data-case="Trailing Inset Edge"] { box-shadow: 4px 0 0 #6366f1 inset; }
|
||||
[data-case="Trailing Inset Token Edge"] { box-shadow: 4px 0 0 var(--brand-accent) inset; }
|
||||
/* The keyword must only be stripped standalone: this token merely contains it. */
|
||||
[data-case="Inset Named Token Edge"] { box-shadow: inset 4px 0 0 var(--inset-accent); }
|
||||
[data-case="Trailing Inset Neutral Edge"] { box-shadow: 4px 0 0 #000 inset; }
|
||||
|
||||
/* Commented-out rules are not live CSS.
|
||||
[data-case="Commented Out Edge"] { box-shadow: inset 4px 0 0 var(--brand-accent); }
|
||||
*/
|
||||
</style>
|
||||
@@ -119,6 +119,9 @@ for (const name of listFixtures()) {
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/sessions/example.jsonl',
|
||||
'.impeccable/live/previews/example/v1.html',
|
||||
'.impeccable/live/artifacts/example-r1.jsx',
|
||||
'.impeccable/live/accept-receipts/example.json',
|
||||
'.impeccable/live/locks/example.lock',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
@@ -127,6 +130,9 @@ for (const name of listFixtures()) {
|
||||
assert.match(ignored, /\.impeccable\/live\/server\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/sessions\/example\.jsonl/);
|
||||
assert.match(ignored, /\.impeccable\/live\/previews\/example\/v1\.html/);
|
||||
assert.match(ignored, /\.impeccable\/live\/artifacts\/example-r1\.jsx/);
|
||||
assert.match(ignored, /\.impeccable\/live\/accept-receipts\/example\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/locks\/example\.lock/);
|
||||
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
|
||||
assert.match(ignored, /src\/lib\/impeccable\/ImpeccableLiveRoot\.svelte/);
|
||||
assert.match(ignored, /src\/lib\/impeccable\/__runtime\.js/);
|
||||
@@ -142,6 +148,15 @@ for (const name of listFixtures()) {
|
||||
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'nuxt') {
|
||||
const plugin = result.results[0];
|
||||
const body = readFileSync(join(tmp, plugin.file), 'utf-8');
|
||||
assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
|
||||
assert.match(body, /impeccable-live-nuxt-plugin/);
|
||||
assert.match(body, /if \(!import\.meta\.dev/);
|
||||
assert.match(body, /localhost:9999\/live\.js/);
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
@@ -169,6 +184,11 @@ for (const name of listFixtures()) {
|
||||
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'nuxt') {
|
||||
assert.equal(result.results[0].removed, true);
|
||||
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
assert.doesNotMatch(body, /impeccable-live-start/);
|
||||
|
||||
@@ -112,6 +112,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
|
||||
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Nuxt + Vite 7 Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<NuxtPage />
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
{
|
||||
"name": "Nuxt 4 + Vue 3 (static fixture only — runtime inject unsupported)",
|
||||
"name": "Nuxt 4 + Vue 3",
|
||||
"config": {
|
||||
"files": ["app.vue"],
|
||||
"insertBefore": "</body>",
|
||||
"files": ["app/app.vue"],
|
||||
"insertBefore": "</template>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["app.vue", "pages/index.vue", "nuxt.config.ts"],
|
||||
"sourceFiles": ["app/app.vue", "app/pages/index.vue", "nuxt.config.ts"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero in pages/index.vue",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "pages/index.vue"
|
||||
"expectedFile": "app/.impeccable-live/wraptest0/manifest.json",
|
||||
"expectedSourceFile": "app/pages/index.vue",
|
||||
"expectedPreviewMode": "vue-component"
|
||||
}
|
||||
],
|
||||
"_runtimeOmitted": "Nuxt's app.vue is a Vue template that compiles to a render function — a <script> tag inserted there renders as a DOM node but does not execute. Nuxt needs a config-based inject (nuxt.config.ts -> app.head.script), which live-inject.mjs does not currently support. Static checks (is-generated, inject syntax, wrap routing) still validate."
|
||||
"runtime": {
|
||||
"styling": "vue-scoped-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund"],
|
||||
"devCommand": ["npm", "run", "dev"],
|
||||
"scheme": "http",
|
||||
"ignoreHTTPSErrors": false,
|
||||
"readyPattern": "Local:\\s+http://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"steer": {
|
||||
"message": "steer-e2e mark hero",
|
||||
"sourceFile": "app/pages/index.vue",
|
||||
"expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,10 @@
|
||||
<article class="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.feature-card {
|
||||
min-height: 64px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,9 @@ import {
|
||||
extractFindingIgnoreValue,
|
||||
resolveProjectPlatform,
|
||||
isNativePlatform,
|
||||
normalizeIgnoreValueEntries,
|
||||
} from '../skill/scripts/hook-lib.mjs';
|
||||
import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs';
|
||||
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
|
||||
|
||||
function mkTmp() {
|
||||
@@ -564,6 +566,132 @@ describe('hook-admin.mjs', () => {
|
||||
assert.match(status, /ignoreValues:\s+overused-font=inter/);
|
||||
});
|
||||
|
||||
// detector.ignoreValues honours a `files` scope, which is the narrowest way to
|
||||
// silence one noisy rule on one file. hook-admin could not write it, so the
|
||||
// only reachable option was ignore-file, which silences every rule for that
|
||||
// file forever.
|
||||
it('ignore-value scopes a wildcard to files via --file', () => {
|
||||
const out = runAdmin([
|
||||
'ignore-value', 'design-system-font-size', '*',
|
||||
'--file', 'src/overlay/widget.js',
|
||||
'--reason', 'Widget builds its own type scale',
|
||||
]);
|
||||
assert.match(out, /scoped to src\/overlay\/widget\.js/);
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
||||
assert.deepEqual(shared.ignoreValues, [{
|
||||
rule: 'design-system-font-size',
|
||||
value: '*',
|
||||
files: ['src/overlay/widget.js'],
|
||||
createdAt: shared.ignoreValues[0].createdAt,
|
||||
reason: 'Widget builds its own type scale',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('ignore-value accepts --file=, --files= and repeated --file', () => {
|
||||
runAdmin(['ignore-value', 'side-tab', '*', '--file=a.css']);
|
||||
runAdmin(['ignore-value', 'side-tab', '*', '--files=b.css']);
|
||||
runAdmin(['ignore-value', 'low-contrast', '*', '--file', 'c.css', '--file', 'd.css']);
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
||||
assert.deepEqual(
|
||||
shared.ignoreValues.map(({ rule, files }) => ({ rule, files })),
|
||||
[
|
||||
{ rule: 'side-tab', files: ['a.css'] },
|
||||
{ rule: 'side-tab', files: ['b.css'] },
|
||||
{ rule: 'low-contrast', files: ['c.css', 'd.css'] },
|
||||
],
|
||||
'each distinct file scope is its own entry; a rule+value-only key overwrote them',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignore-value refuses a wildcard with no file scope', () => {
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'design-system-font-size', '*']),
|
||||
/Wildcard value ignores must be scoped with --file/,
|
||||
'a bare wildcard is ignore-rule\'s job, not a per-file waiver',
|
||||
);
|
||||
assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config');
|
||||
});
|
||||
|
||||
it('ignore-value --file requires a glob', () => {
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'side-tab', '*', '--file']),
|
||||
/--file requires a glob/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignore-value rejects an unknown flag instead of folding it into the value', () => {
|
||||
// `--shard` (a typo for --shared) used to store the value "inter --shard",
|
||||
// which matches nothing, while reporting a successful suppression.
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shard']),
|
||||
/Unknown ignore-value flag: --shard/,
|
||||
);
|
||||
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
|
||||
});
|
||||
|
||||
// Every write runs the entries through normalizeIgnoreValueEntries. Emitting a
|
||||
// different key order than the one on disk rewrote all untouched entries.
|
||||
it('an unrelated edit leaves existing ignoreValues byte-identical', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
const seeded = {
|
||||
detector: {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [
|
||||
{
|
||||
rule: 'bounce-easing',
|
||||
value: 'bounce-ball',
|
||||
createdAt: '2026-06-15T04:15:03.164Z',
|
||||
reason: 'Intentional',
|
||||
},
|
||||
{
|
||||
rule: 'design-system-color',
|
||||
value: '*',
|
||||
files: ['site/styles/demo.css'],
|
||||
createdAt: '2026-06-15T23:37:38.170Z',
|
||||
reason: 'Deliberate off-system demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify(seeded, null, 2) + '\n');
|
||||
const before = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector.ignoreValues;
|
||||
|
||||
runAdmin(['ignore-file', 'some/other/**']);
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
||||
assert.deepEqual(after.ignoreFiles, ['some/other/**'], 'the intended change still lands');
|
||||
assert.equal(
|
||||
JSON.stringify(after.ignoreValues),
|
||||
JSON.stringify(before),
|
||||
'untouched ignoreValues must keep their exact key order, or every config diff churns',
|
||||
);
|
||||
});
|
||||
|
||||
// hook-lib.mjs (skill, ships into harness dirs) and cli/lib/impeccable-config.mjs
|
||||
// (CLI + Pages functions) carry independent copies of this normalizer by
|
||||
// necessity. They write the same file, so a key-order drift between them makes
|
||||
// the config churn depending on which tool touched it last.
|
||||
it('both config normalizers emit identical entries', () => {
|
||||
const input = [
|
||||
{ rule: 'BOUNCE-EASING', value: 'Bounce-Ball', reason: ' r ', createdAt: '2026-01-01T00:00:00.000Z' },
|
||||
{ rule: 'design-system-color', value: '*', files: [' a.css ', 'b.css', 'a.css'], createdAt: '2026-02-02T00:00:00.000Z' },
|
||||
{ rule: 'side-tab', value: '*', file: 'legacy.css' },
|
||||
{ rule: '', value: 'dropped' },
|
||||
];
|
||||
assert.equal(
|
||||
JSON.stringify(normalizeIgnoreValueEntries(input)),
|
||||
JSON.stringify(normalizeIgnoreValueEntriesCli(input)),
|
||||
'skill/scripts/hook-lib.mjs and cli/lib/impeccable-config.mjs must agree, key order included',
|
||||
);
|
||||
// And pin the canonical order itself, which is what the config on disk uses.
|
||||
const full = { rule: 'side-tab', value: '*', files: ['a.css'], createdAt: '2026-01-01T00:00:00.000Z', reason: 'r' };
|
||||
assert.deepEqual(
|
||||
Object.keys(normalizeIgnoreValueEntries([full])[0]),
|
||||
['rule', 'value', 'files', 'createdAt', 'reason'],
|
||||
);
|
||||
});
|
||||
|
||||
it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
// A recorded per-developer consent in the local file...
|
||||
|
||||
+199
-2
@@ -5,11 +5,12 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { sourceLockPath } from '../skill/scripts/live/source-lock.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ACCEPT = resolve(__dirname, '..', 'skill/scripts/live-accept.mjs');
|
||||
@@ -29,6 +30,175 @@ function runAccept(cwd, args) {
|
||||
}
|
||||
}
|
||||
|
||||
// The failure that broke the first real Claude Code Live run. Progressive
|
||||
// publication stages `.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, which
|
||||
// carries the session marker. findSessionFile walks `src`, `app`, `pages`, ... and
|
||||
// then `.`; a project whose source is not under one of those (this repo's own site
|
||||
// lives in `site/pages/`) falls through to the `.` walk, where dot-directories sort
|
||||
// before letters — so the artifact was found before the real file.
|
||||
describe('live-accept — marker search must ignore Impeccable state', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-decoy-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const SOURCE = [
|
||||
'<main>',
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'</main>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function seed({ revisions = 3 } = {}) {
|
||||
mkdirSync(join(tmp, 'site', 'pages'), { recursive: true });
|
||||
mkdirSync(join(tmp, '.impeccable', 'live', 'artifacts'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'site', 'pages', 'index.astro'), SOURCE);
|
||||
for (let r = 1; r <= revisions; r += 1) {
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'artifacts', `ab12cd34-r${r}.astro`), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts into real source when a staged artifact carries the same marker', () => {
|
||||
seed();
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.equal(
|
||||
result.file,
|
||||
'site/pages/index.astro',
|
||||
'accept must resolve the project file, not the .impeccable artifact decoy',
|
||||
);
|
||||
const source = readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8');
|
||||
assert.match(source, /VARIANT ONE/);
|
||||
assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source');
|
||||
});
|
||||
|
||||
it('retires the session’s staged artifacts and leaves other sessions alone', () => {
|
||||
seed();
|
||||
const dir = join(tmp, '.impeccable', 'live', 'artifacts');
|
||||
writeFileSync(join(dir, 'ffff0000-r1.astro'), SOURCE);
|
||||
runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||||
assert.equal(existsSync(join(dir, 'ab12cd34-r1.astro')), false, 'own artifacts must not outlive the session');
|
||||
assert.equal(existsSync(join(dir, 'ab12cd34-r3.astro')), false);
|
||||
assert.equal(existsSync(join(dir, 'ffff0000-r1.astro')), true, 'another session’s artifacts must survive');
|
||||
});
|
||||
|
||||
it('discards into real source with an artifact decoy present', () => {
|
||||
seed({ revisions: 1 });
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--discard']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.equal(result.file, 'site/pages/index.astro');
|
||||
assert.match(readFileSync(join(tmp, 'site', 'pages', 'index.astro'), 'utf-8'), /ORIGINAL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-accept — session id validation', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-id-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// --id becomes a path segment for the accept receipt. Traversal here wrote
|
||||
// JSON to arbitrary absolute paths (e.g. `--id ../../../../etc/evil`).
|
||||
for (const id of ['../../../../etc/evil', 'a/b', '..', 'a\\b', '']) {
|
||||
it(`refuses --id ${JSON.stringify(id)} without writing a receipt`, () => {
|
||||
const res = spawnSync('node', [ACCEPT, '--id', id, '--discard'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.equal(res.status, 1, 'must exit non-zero');
|
||||
assert.match(res.stderr, /Invalid --id|Missing --id/);
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false);
|
||||
});
|
||||
}
|
||||
|
||||
it('still accepts a well-formed id', () => {
|
||||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--discard'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.doesNotMatch(res.stderr || '', /Invalid --id/);
|
||||
});
|
||||
|
||||
// --variant is interpolated into a RegExp and into the markup written back to
|
||||
// source. `.*` matched the `original` block first, so the CLI reported a
|
||||
// successful accept while actually restoring the original.
|
||||
for (const variant of ['.*', '[12]', 'original', '1e2', '']) {
|
||||
it(`refuses --variant ${JSON.stringify(variant)} rather than matching by regex`, () => {
|
||||
writeFileSync(join(tmp, 'page.html'), [
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL CONTENT</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'',
|
||||
].join('\n'));
|
||||
const res = spawnSync('node', [ACCEPT, '--id', 'ab12cd34', '--variant', variant], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.stderr, /Invalid --variant|Need --discard/);
|
||||
assert.match(
|
||||
readFileSync(join(tmp, 'page.html'), 'utf-8'),
|
||||
/impeccable-variants-start/,
|
||||
'a rejected variant must leave the wrapper untouched',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The plain wrapper is the only non-component preview path now that the isolated
|
||||
// source-artifact mode is gone, so its lock-contention behaviour is what carries
|
||||
// these guarantees.
|
||||
describe('live-accept — plain wrapper under source-lock contention', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-lock-')); });
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const PAGE = [
|
||||
'<!-- impeccable-variants-start ab12cd34 -->',
|
||||
'<div data-impeccable-variant="original">ORIGINAL</div>',
|
||||
'<div data-impeccable-variant="1">VARIANT ONE</div>',
|
||||
'<!-- impeccable-variants-end ab12cd34 -->',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function holdLock() {
|
||||
// realpath: mkdtemp hands back /var/... on macOS while the child's cwd
|
||||
// resolves to /private/var/..., and the lock digest hashes the absolute path.
|
||||
const realTmp = realpathSync(tmp);
|
||||
const lockPath = sourceLockPath(join(realTmp, 'page.html'), realTmp);
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
// process.pid is alive, so the lock is a live holder rather than stale.
|
||||
writeFileSync(lockPath, JSON.stringify({
|
||||
owner: 'generation:ab12cd34:1', token: 'other', pid: process.pid, at: Date.now(),
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
for (const [label, args] of [['accept', ['--variant', '1']], ['discard', ['--discard']]]) {
|
||||
it(`reports a blocked ${label} as mode:error rather than a manual handoff`, () => {
|
||||
writeFileSync(join(tmp, 'page.html'), PAGE);
|
||||
holdLock();
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', ...args]);
|
||||
assert.equal(result.handled, false, JSON.stringify(result));
|
||||
assert.equal(result.error, 'source_locked');
|
||||
// Without mode:error, completion.mjs classifies this as agent_done with an ok
|
||||
// ack and live.md tells the agent to hand-edit the file — racing the publisher
|
||||
// that holds the lock.
|
||||
assert.equal(result.mode, 'error');
|
||||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), PAGE, 'source must be untouched');
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'accept-receipts')), false, 'no receipt for a failed op');
|
||||
});
|
||||
}
|
||||
|
||||
it('succeeds once the lock is gone', () => {
|
||||
writeFileSync(join(tmp, 'page.html'), PAGE);
|
||||
const result = runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
|
||||
assert.equal(result.handled, true, JSON.stringify(result));
|
||||
assert.match(readFileSync(join(tmp, 'page.html'), 'utf-8'), /VARIANT ONE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-accept — style-element edge cases', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-accept-test-')); });
|
||||
@@ -74,6 +244,33 @@ describe('live-accept — style-element edge cases', () => {
|
||||
assert.ok(!after.includes('original text'), 'original content dropped');
|
||||
});
|
||||
|
||||
it('replays a durable receipt when Accept is retried after source was already written', () => {
|
||||
const html = `<body>
|
||||
<!-- impeccable-variants-start RECEIPT1 -->
|
||||
<div data-impeccable-variants="RECEIPT1" data-impeccable-variant-count="2" style="display: contents">
|
||||
<div data-impeccable-variant="original"><p>original</p></div>
|
||||
<style data-impeccable-css="RECEIPT1" />
|
||||
<div data-impeccable-variant="1"><p>accepted once</p></div>
|
||||
<div data-impeccable-variant="2" style="display: none"><p>other</p></div>
|
||||
</div>
|
||||
<!-- impeccable-variants-end RECEIPT1 -->
|
||||
</body>`;
|
||||
writeFileSync(join(tmp, 'page.html'), html);
|
||||
|
||||
const first = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||||
const afterFirst = readFileSync(join(tmp, 'page.html'), 'utf-8');
|
||||
const replay = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '1']);
|
||||
|
||||
assert.equal(first.handled, true);
|
||||
assert.equal(replay.handled, true);
|
||||
assert.equal(replay.alreadyApplied, true);
|
||||
assert.equal(replay.file, 'page.html');
|
||||
assert.equal(readFileSync(join(tmp, 'page.html'), 'utf-8'), afterFirst);
|
||||
const conflict = runAccept(tmp, ['--id', 'RECEIPT1', '--variant', '2']);
|
||||
assert.equal(conflict.handled, false);
|
||||
assert.equal(conflict.error, 'accept_receipt_conflict');
|
||||
});
|
||||
|
||||
// Variant: same-line <style>…</style> block should also be treated as a
|
||||
// single skipped unit; the line has both open and close tags.
|
||||
it('finds the accepted variant after a single-line <style>…</style> block', () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assembleSplitProgressiveOutput,
|
||||
buildInteractionRun,
|
||||
compareModelBackedReports,
|
||||
createTraceRecorder,
|
||||
durationBetween,
|
||||
summarizeRuns,
|
||||
} from '../scripts/lib/live-benchmark.mjs';
|
||||
|
||||
describe('live benchmark metrics', () => {
|
||||
it('keeps published progressive CSS byte-stable and carries deferred params', () => {
|
||||
const firstCss = '@scope ([data-impeccable-variant="1"]) { .offer { color: red; } }';
|
||||
const laterCss = [
|
||||
'@scope ([data-impeccable-variant="2"]) { .offer { color: green; } }',
|
||||
'@scope ([data-impeccable-variant="3"]) { .offer { color: blue; } }',
|
||||
].join('\n');
|
||||
const firstVariant = { innerHtml: '<article class="offer">One</article>', params: [] };
|
||||
const deferredParams = [{ name: 'density', type: 'range', min: 0, max: 1, default: 0.5 }];
|
||||
const assembled = assembleSplitProgressiveOutput(
|
||||
{ scopedCss: firstCss, variants: [firstVariant] },
|
||||
{
|
||||
scopedCss: laterCss,
|
||||
variants: [
|
||||
{ innerHtml: firstVariant.innerHtml, params: deferredParams },
|
||||
{ innerHtml: '<article class="offer">Two</article>', params: [] },
|
||||
{ innerHtml: '<article class="offer">Three</article>', params: [] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(assembled.scopedCss, `${firstCss}\n${laterCss}`);
|
||||
assert.equal(assembled.scopedCss.slice(0, firstCss.length), firstCss);
|
||||
assert.equal(assembled.variants[0].innerHtml, firstVariant.innerHtml);
|
||||
assert.equal(assembled.variants[0].params, deferredParams);
|
||||
});
|
||||
|
||||
it('rejects tail CSS that would reproduce published_variant_css_changed', () => {
|
||||
const first = {
|
||||
scopedCss: '@scope ([data-impeccable-variant="1"]) { .offer { color: red; } }',
|
||||
variants: [{ innerHtml: '<article class="offer">One</article>', params: [] }],
|
||||
};
|
||||
const conflictingTail = {
|
||||
scopedCss: [
|
||||
'@scope ([data-impeccable-variant="1"]) { .offer { color: purple; } }',
|
||||
'@scope ([data-impeccable-variant="2"]) { .offer { color: green; } }',
|
||||
].join('\n'),
|
||||
variants: [
|
||||
{ innerHtml: first.variants[0].innerHtml, params: [] },
|
||||
{ innerHtml: '<article class="offer">Two</article>', params: [] },
|
||||
],
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => assembleSplitProgressiveOutput(first, conflictingTail),
|
||||
/must not repeat or conflict with published variant 1 CSS/,
|
||||
);
|
||||
});
|
||||
|
||||
it('separates model generation from Impeccable overhead', () => {
|
||||
const events = [
|
||||
{ name: 'ui.go.start', at: 100, iteration: 1 },
|
||||
{ name: 'browser.generate_post', at: 108, id: 'abc', hasScreenshotPath: false, commentCount: 0, strokeCount: 0 },
|
||||
{ name: 'agent.event.received', at: 110, id: 'abc', type: 'generate' },
|
||||
{ name: 'agent.scaffold.start', at: 112, id: 'abc' },
|
||||
{ name: 'agent.scaffold.end', at: 132, id: 'abc' },
|
||||
{ name: 'agent.generate.start', at: 132, id: 'abc' },
|
||||
{ name: 'agent.generate.first_ready', at: 1132, id: 'abc' },
|
||||
{ name: 'agent.generate.end', at: 1132, id: 'abc' },
|
||||
{ name: 'agent.write.start', at: 1132, id: 'abc' },
|
||||
{ name: 'agent.write.end', at: 1142, id: 'abc' },
|
||||
{ name: 'agent.reply.start', at: 1142, id: 'abc' },
|
||||
{ name: 'agent.reply.end', at: 1147, id: 'abc' },
|
||||
{ name: 'browser.first_variant', at: 1200, iteration: 1 },
|
||||
{ name: 'browser.all_variants', at: 1200, iteration: 1 },
|
||||
];
|
||||
|
||||
const run = buildInteractionRun(events, {
|
||||
iteration: 1,
|
||||
scenario: 'plain',
|
||||
goStartedAt: 100,
|
||||
browserTiming: { goAt: 50, generateAt: 52.5 },
|
||||
});
|
||||
assert.equal(run.goToFirstVariantMs, 1094.5);
|
||||
assert.equal(run.browserPreparationMs, 8);
|
||||
assert.equal(run.browserDispatchMs, 2.5);
|
||||
assert.equal(run.automationClickMs, 5.5);
|
||||
assert.deepEqual(run.annotationEvidence, { screenshotPath: false, comments: 0, strokes: 0 });
|
||||
assert.equal(run.serverPickupMs, 2);
|
||||
assert.equal(run.generationMs, 1000);
|
||||
assert.equal(run.impeccableOverheadMs, 94.5);
|
||||
assert.equal(run.deliveryGapMs, 0);
|
||||
assert.equal(run.scaffoldMs, 20);
|
||||
});
|
||||
|
||||
it('reports interpolated medians and p95 values', () => {
|
||||
const summary = summarizeRuns([
|
||||
{ goToFirstVariantMs: 100, generationMs: 70 },
|
||||
{ goToFirstVariantMs: 200, generationMs: 140 },
|
||||
{ goToFirstVariantMs: 300, generationMs: 210 },
|
||||
]);
|
||||
assert.equal(summary.metrics.goToFirstVariantMs.median, 200);
|
||||
assert.equal(summary.metrics.goToFirstVariantMs.p95, 290);
|
||||
});
|
||||
|
||||
it('records monotonic trace events and returns null for missing boundaries', () => {
|
||||
let now = 0;
|
||||
const recorder = createTraceRecorder(() => ++now);
|
||||
recorder.trace('start');
|
||||
recorder.trace('end');
|
||||
assert.equal(durationBetween(recorder.events, 'start', 'end'), 1);
|
||||
assert.equal(durationBetween(recorder.events, 'missing', 'end'), null);
|
||||
});
|
||||
|
||||
it('proves model-backed first-reviewable thresholds with comparable reports', () => {
|
||||
const atomic = modelReport('atomic', 1000, 1200, 1400, 1500);
|
||||
const progressive = modelReport('progressive', 500, 700, 1450, 1550);
|
||||
const comparison = compareModelBackedReports(atomic, progressive);
|
||||
assert.equal(comparison.passed, true);
|
||||
assert.equal(comparison.target.medianImprovement, 0.5);
|
||||
assert.equal(comparison.target.p95Improvement, 0.4167);
|
||||
});
|
||||
|
||||
it('rejects fake, simulated, and mismatched model reports', () => {
|
||||
const atomic = modelReport('atomic', 1000, 1200, 1400, 1500);
|
||||
const progressive = modelReport('progressive', 500, 700, 1450, 1550);
|
||||
assert.throws(
|
||||
() => compareModelBackedReports({ ...atomic, benchmark: { ...atomic.benchmark, agent: 'fake' } }, progressive),
|
||||
/model-backed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => compareModelBackedReports(atomic, { ...progressive, benchmark: { ...progressive.benchmark, simulation: { remainingGenerationMs: 1 } } }),
|
||||
/simulated latency/,
|
||||
);
|
||||
assert.throws(
|
||||
() => compareModelBackedReports(atomic, { ...progressive, benchmark: { ...progressive.benchmark, model: 'other-model' } }),
|
||||
/benchmark mismatch for model/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function modelReport(delivery, firstMedian, firstP95, allMedian, allP95) {
|
||||
return {
|
||||
benchmark: {
|
||||
fixture: 'vite8-react-plain',
|
||||
agent: 'llm',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-haiku-4-5',
|
||||
scenario: 'plain',
|
||||
variants: 3,
|
||||
delivery,
|
||||
promptMode: 'synthetic-element-contract',
|
||||
simulation: null,
|
||||
},
|
||||
summary: {
|
||||
count: 5,
|
||||
metrics: {
|
||||
goToFirstVariantMs: { median: firstMedian, p95: firstP95 },
|
||||
goToAllVariantsMs: { median: allMedian, p95: allP95 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,7 +74,7 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a Svelte-gated painted-ancestor crop proxy for shader capture', () => {
|
||||
it('uses a framework-component-gated painted-ancestor crop proxy for shader capture', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findShaderProxyCaptureRoot\(el\) \{[\s\S]{0,500}?let node = el\.parentElement;[\s\S]{0,700}?containsElement && paintsShaderProxySurface\(node\)[\s\S]{0,120}?return null;/,
|
||||
@@ -87,8 +87,8 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?currentPreviewMode === 'svelte-component' \|\| svelteComponentSession[\s\S]{0,260}?dataset\?\.impeccablePreview === 'svelte-component';/,
|
||||
'ancestor crop proxy must be gated to the Svelte adapter / Svelte component previews',
|
||||
/function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?isFrameworkComponentPreviewMode\(currentPreviewMode\) \|\| svelteComponentSession[\s\S]{0,260}?isFrameworkComponentPreviewMode\(wrapper\?\.dataset\?\.impeccablePreview\);/,
|
||||
'ancestor crop proxy must be gated to Svelte/Vue component previews',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -141,11 +141,30 @@ describe('live-browser.js regression guards', () => {
|
||||
it('restores unsaved inline edit drafts before hideBar tears editing down', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function hideBar\(\) \{[\s\S]{0,620}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
|
||||
/function hideBar\(instant\) \{[\s\S]{0,720}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/,
|
||||
'hideBar should not leave unsaved contenteditable drafts in the DOM when an external event hides the bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('discards variants without hiding the original or animating stale chrome', () => {
|
||||
assert.match(SOURCE, /function showOriginalDuringDiscard\(sessionId\)[\s\S]{0,900}?data-impeccable-variant="original"/);
|
||||
assert.match(SOURCE, /function handleDiscard\(\)[\s\S]{0,420}?cleanup\(\{ restoreOriginal: true, instantChrome: true \}\)/);
|
||||
assert.match(SOURCE, /if \(instant\) barEl\.style\.display = 'none'/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(restoreOriginal\) showOriginalDuringDiscard\(cleanupSessionId\);\s*else wrapper\.style\.display = 'none';/,
|
||||
'only non-discard cleanup may blank the wrapper while waiting for HMR',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores live state off the document root and preserves the selected anchor top', () => {
|
||||
assert.match(SOURCE, /window\.__IMPECCABLE_LIVE_STATE__ = next/);
|
||||
assert.doesNotMatch(SOURCE, /document\.documentElement\.dataset\.impeccableLiveState/);
|
||||
assert.match(SOURCE, /pickedAnchorViewportTop: Number\.isFinite\(pickedAnchorViewportTop\)/);
|
||||
assert.match(SOURCE, /scrollLockAnchorTop = typeof initialAnchorTop === 'number' && isFinite\(initialAnchorTop\)/);
|
||||
assert.match(SOURCE, /const anchorDelta = anchorTop - scrollLockAnchorTop/);
|
||||
});
|
||||
|
||||
it('does not autofocus the steering chat while inline editing', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -443,6 +462,25 @@ describe('live-browser.js regression guards', () => {
|
||||
/function syncAgentPollingUi\(/,
|
||||
'global bar brand must reflect agent poll connectivity',
|
||||
);
|
||||
// The indicator goes quiet both when nobody is polling and when the agent
|
||||
// holds leased work. Under one-shot foreground polling the second case is
|
||||
// every normal generation, so a single "run live-poll.mjs to connect" tip
|
||||
// told users to fix a healthy session.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function agentHasWorkInFlight\(\)\s*\{\s*return state === 'GENERATING' \|\| state === 'SAVING';/,
|
||||
'agent poll copy must distinguish a busy agent from an absent one',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/agentHasWorkInFlight\(\) \? AGENT_BUSY_TIP : AGENT_DISCONNECTED_TIP/,
|
||||
'a busy agent must not be described as disconnected',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/tip\.textContent = agentStatusText\(\)/,
|
||||
'tooltip copy must be derived at display time, not read from a cache the 5s status poll last wrote',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'agent_polling':/,
|
||||
@@ -841,6 +879,58 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes every arrived progressive variant immediately actionable', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(arrivedVariants > 0\) \{[\s\S]{0,180}?setLiveState\('CYCLING'\)/,
|
||||
'the first arrived variant should leave the generating-only state',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?accept\.style\.pointerEvents = 'none'/,
|
||||
'Accept must not wait for variants the user did not choose',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/arrivedVariants < expectedVariants\) \{[\s\S]{0,180}?discard\.style\.pointerEvents = 'none'/,
|
||||
'Discard must cancel remaining work immediately',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const resumedState = arrivedVariants > 0 \? 'CYCLING' : 'GENERATING'/,
|
||||
'reload recovery should preserve a partially delivered review state',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/arrivedVariants >= expectedVariants && expectedVariants > 0[\s\S]{0,100}?\? 'variants_ready'[\s\S]{0,60}?: 'variants_progress'/,
|
||||
'checkpoint timing must distinguish partial review from complete delivery by counts',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps deferred Tune controls visible and refreshes params-only publications', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const paramsPending = !hasParams && \(parameterGenerationState === 'pending' \|\| parameterGenerationState === 'loading'\)/,
|
||||
'the cycling bar must expose Tune while parameter generation is outstanding',
|
||||
);
|
||||
assert.match(SOURCE, /tune\.disabled = true/, 'pending Tune must be visibly loading but non-interactive');
|
||||
assert.match(SOURCE, /Tune controls are ready\./, 'parameter arrival needs a clear ready indication');
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/msg\.publicationKind !== 'params' && arrivedVariants >= targetArrived/,
|
||||
'a params-only publication must refresh even though the variant count is unchanged',
|
||||
);
|
||||
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
|
||||
});
|
||||
|
||||
it('promotes an early-accepted Svelte preview before releasing the picker', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function scheduleAcceptCleanup\(accepted\) \{[\s\S]{0,420}?if \(accepted\?\.isSvelteComponent\) \{[\s\S]{0,120}?commitAcceptedSvelteComponentToDom\(accepted\.id\);[\s\S]{0,120}?cleanupAcceptedSession\(\);/,
|
||||
'Svelte early accept must tear down its adapter mount before the next picking session starts',
|
||||
);
|
||||
});
|
||||
|
||||
it('variant injection resolves the picked anchor before entering recovery', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
|
||||
@@ -5,8 +5,48 @@ import { join } from 'node:path';
|
||||
|
||||
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
|
||||
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
|
||||
|
||||
describe('live-browser source contracts', () => {
|
||||
it('reports foreground poll connectivity without a background worker dependency', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/syncAgentPollingUi\(!!msg\.agentPolling\)/,
|
||||
'the initial SSE state should include foreground poll connectivity',
|
||||
);
|
||||
assert.doesNotMatch(SOURCE, /codexWorker|codex-worker|codex_cli_unavailable/);
|
||||
});
|
||||
|
||||
it('routes Nuxt Vue preview modules through the Vite build-assets base', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function resolveComponentModuleUrl\(manifest, modulePath\)[\s\S]*?manifest\?\.previewMode === 'vue-component'[\s\S]*?window\.__NUXT__\?\.config\?\.app\?\.buildAssetsDir[\s\S]*?pathValue\.slice\('\/@fs\/'.length\)/,
|
||||
'Nuxt must not send app-local preview modules through the page-route fallback',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const moduleBase = manifest\.componentModuleBase[\s\S]*?resolveComponentModuleUrl\(manifest, modulePath\)/,
|
||||
'Vue SFC variants should use the manifest Vite module base rather than componentDir as a route URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches plain generation before screenshot capture without bypassing annotated evidence', () => {
|
||||
const dispatchIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await sendEvent(basePayload);');
|
||||
const captureIndex = CAPTURE_AND_EMIT_SOURCE.indexOf('await captureElementToBlob');
|
||||
assert.ok(dispatchIndex >= 0, 'plain generation should dispatch immediately');
|
||||
assert.ok(captureIndex > dispatchIndex, 'plain generation dispatch must happen before capture begins');
|
||||
assert.match(
|
||||
CAPTURE_AND_EMIT_SOURCE,
|
||||
/if \(blob && hasAnnotations\)[\s\S]*?\/annotation\?token=/,
|
||||
'annotation screenshots should still upload before annotated generation dispatch',
|
||||
);
|
||||
assert.match(
|
||||
CAPTURE_AND_EMIT_SOURCE,
|
||||
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
|
||||
'annotated generation should dispatch exactly after capture and upload resolve',
|
||||
);
|
||||
});
|
||||
|
||||
it('saves copy edits to the staged buffer with rich AI context', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
@@ -285,7 +325,7 @@ describe('live-browser source contracts', () => {
|
||||
assert.match(SOURCE, /sendEvent\(\{ type: 'discard', id: currentSessionId \}, \{ throwOnError: true \}\)/);
|
||||
});
|
||||
|
||||
it('waits for post-carbonize completion before final accepted DOM cleanup', () => {
|
||||
it('releases the foreground picker after deterministic accept while carbonize finishes', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/let pendingAcceptedSession = null;/,
|
||||
@@ -309,8 +349,8 @@ describe('live-browser source contracts', () => {
|
||||
const agentDoneStart = SOURCE.indexOf("case 'agent_done':");
|
||||
const errorCaseStart = SOURCE.indexOf("case 'error':", agentDoneStart);
|
||||
const agentDoneSource = SOURCE.slice(agentDoneStart, errorCaseStart);
|
||||
assert.match(agentDoneSource, /Carbonize accepts are not terminal/);
|
||||
assert.match(agentDoneSource, /break;/);
|
||||
assert.match(agentDoneSource, /must not hold the foreground picker hostage/);
|
||||
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
|
||||
@@ -319,15 +359,15 @@ describe('live-browser source contracts', () => {
|
||||
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
|
||||
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
|
||||
const handleAcceptSource = SOURCE.slice(handleAcceptStart, maybeCompleteStart);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
handleAcceptSource,
|
||||
/state = 'CONFIRMED'|cleanupAcceptedSession\(|hideBar\(\)/,
|
||||
'accept enqueue should not clear or confirm the browser session before source cleanup completes',
|
||||
/sendEvent\(acceptPayload, \{ throwOnError: true \}\)[\s\S]*?markSessionHandled\(\);[\s\S]*?setLiveState\('CONFIRMED'\);[\s\S]*?scheduleAcceptCleanup\(pending\);/,
|
||||
'durable accept intent should release the foreground picker before background source cleanup completes',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?acceptedDomAlreadyClean\(accepted\)[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?\}, 1800\);/,
|
||||
'post-cleanup fallback should give HMR a second chance before mutating React-owned DOM',
|
||||
/function scheduleAcceptCleanup\(accepted\)[\s\S]*?queueMicrotask\(function\(\) \{[\s\S]*?cleanupAcceptedSession\(\);[\s\S]*?setTimeout\(function\(\) \{[\s\S]*?ensureAcceptedDomClean\(accepted\);[\s\S]*?\}, 1200\);/,
|
||||
'foreground cleanup should be immediate while the no-HMR DOM fallback stays deferred',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
@@ -393,4 +433,12 @@ describe('live-browser source contracts', () => {
|
||||
'source fallback should translate simple JSX style objects such as display:none',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads progressive source checkpoints through the no-HMR fallback', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
|
||||
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,28 @@ describe('live completion type classification', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Component previews keep their variants in module files, not in the user's
|
||||
// source, so a failed accept leaves nothing to hand-edit: that is a failure, not
|
||||
// live.md's "read file, find markers, edit" handoff. Only svelte-component was
|
||||
// special cased, so the identical failure on a Vue preview read as success.
|
||||
for (const previewMode of ['svelte-component', 'vue-component']) {
|
||||
it(`treats a failed ${previewMode} accept as an error, not a manual handoff`, () => {
|
||||
assert.equal(
|
||||
completionTypeForAcceptResult('accept', { handled: false, error: 'source_locked', previewMode }),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('still treats a failed plain-wrapper accept as a manual handoff', () => {
|
||||
// The one shape with editable markers in source. This must not regress into
|
||||
// an error, or every hand-editable session starts failing the poll loop.
|
||||
assert.equal(
|
||||
completionTypeForAcceptResult('accept', { handled: false, error: 'Markers not found' }),
|
||||
'agent_done',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies handled accept/discard and real failures explicitly', () => {
|
||||
assert.equal(completionTypeForAcceptResult('accept', { handled: true }), 'complete');
|
||||
assert.equal(completionTypeForAcceptResult('discard', { handled: true }), 'discarded');
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { htmlToJsx, normalizeVariantOutput } from './live-e2e/agent.mjs';
|
||||
import {
|
||||
htmlToJsx,
|
||||
isExpectedGenerationCancellation,
|
||||
normalizeVariantOutput,
|
||||
} from './live-e2e/agent.mjs';
|
||||
|
||||
describe('live-e2e agent output translation', () => {
|
||||
it('treats a fenced late generation as expected cancellation only', () => {
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('Source publication prepare failed: stale_generation_epoch')), true);
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('Source publication failed: stale_source_revision')), false);
|
||||
assert.equal(isExpectedGenerationCancellation(new Error('provider unavailable')), false);
|
||||
});
|
||||
|
||||
it('converts HTML class and inline style attributes to JSX syntax', () => {
|
||||
const jsx = htmlToJsx(
|
||||
'<h1 class="hero-title" style="--p-scale:1; font-size:2.25rem; font-weight:700">Title</h1>',
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
createLlmAgent,
|
||||
parseManualEditResponse,
|
||||
parseVariantResponse,
|
||||
progressiveVariantGuidance,
|
||||
resolveLlmAgentConfig,
|
||||
validateManualEditCoverage,
|
||||
validateManualEditPlanningCoverage,
|
||||
validateVariantMaterialChange,
|
||||
validateVariantCount,
|
||||
validateProgressiveVariantOutput,
|
||||
validateVariantVisibleCopy,
|
||||
} from './live-e2e/agents/llm-agent.mjs';
|
||||
|
||||
@@ -1459,6 +1462,19 @@ describe('live-e2e LLM agent manual edit coverage validation', () => {
|
||||
});
|
||||
|
||||
describe('live-e2e LLM agent variant prompt', () => {
|
||||
it('makes progressive phase boundaries and lazy parameters explicit', () => {
|
||||
const first = progressiveVariantGuidance({ count: 1, progressive: { phase: 'first' } });
|
||||
const remaining = progressiveVariantGuidance({
|
||||
count: 3,
|
||||
progressive: { phase: 'remaining', omitFirstVariantCss: true },
|
||||
});
|
||||
assert.match(first, /params: \[\]/);
|
||||
assert.match(first, /materially different/);
|
||||
assert.match(remaining, /complete final set of exactly 3 variants/);
|
||||
assert.match(remaining, /Keep its innerHtml exactly unchanged/);
|
||||
assert.match(remaining, /Do not repeat or modify any scopedCss rule/);
|
||||
});
|
||||
|
||||
it('tells the model not to nest duplicate picked containers', () => {
|
||||
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /replacement root itself/);
|
||||
assert.match(VARIANT_SYSTEM_INSTRUCTIONS, /do not wrap a duplicate/);
|
||||
@@ -1484,6 +1500,53 @@ describe('live-e2e LLM agent variant prompt', () => {
|
||||
});
|
||||
|
||||
describe('live-e2e LLM agent variant copy validation', () => {
|
||||
it('enforces the exact requested variant count', () => {
|
||||
const parsed = { scopedCss: '', variants: [{ innerHtml: '<h1>One</h1>', params: [] }] };
|
||||
assert.match(validateVariantCount(parsed, { count: 2 }), /expected exactly 2 variants, received 1/);
|
||||
assert.equal(validateVariantCount(parsed, { count: 1 }), null);
|
||||
});
|
||||
|
||||
it('defers progressive params and preserves the visible first variant', () => {
|
||||
const firstHtml = '<h1 class="hero-title"><span>One</span></h1>';
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: firstHtml, params: [{ id: 'weight' }] }] },
|
||||
{ progressive: { phase: 'first' } },
|
||||
),
|
||||
/defer params/,
|
||||
);
|
||||
assert.equal(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: firstHtml, params: [] }] },
|
||||
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{ variants: [{ innerHtml: '<h1>Changed</h1>', params: [] }] },
|
||||
{ progressive: { phase: 'remaining', firstVariant: { innerHtml: firstHtml } } },
|
||||
),
|
||||
/preserve variant 1/,
|
||||
);
|
||||
assert.match(
|
||||
validateProgressiveVariantOutput(
|
||||
{
|
||||
scopedCss: '@scope ([data-impeccable-variant="1"]) { .hero-title { color: red; } }',
|
||||
variants: [{ innerHtml: firstHtml, params: [] }],
|
||||
},
|
||||
{
|
||||
progressive: {
|
||||
phase: 'remaining',
|
||||
firstVariant: { innerHtml: firstHtml },
|
||||
omitFirstVariantCss: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
/omit already-published variant 1 CSS/,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows variants that preserve the picked element text', () => {
|
||||
const result = validateVariantVisibleCopy(
|
||||
{
|
||||
|
||||
+377
-11
@@ -22,7 +22,7 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
clickAccept,
|
||||
clickApplyEdits,
|
||||
clickEditCopy,
|
||||
clickDiscard,
|
||||
clickSaveEdit,
|
||||
clickGo,
|
||||
clickNext,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
editTextLeaf,
|
||||
drawAnnotationPinAndStroke,
|
||||
getVisibleVariant,
|
||||
installLiveQueryHelpers,
|
||||
pickElement,
|
||||
runLiveChromeBottomBarSmoke,
|
||||
waitForApplyDockHidden,
|
||||
@@ -220,7 +222,7 @@ for (const { name, fixture } of fixtures) {
|
||||
const domSelector = isInsert
|
||||
? insertDomSelector
|
||||
: pickSelector;
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture);
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
|
||||
const variantContentSelector = isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
|
||||
: usesSvelteComponentPreview
|
||||
@@ -314,10 +316,11 @@ for (const { name, fixture } of fixtures) {
|
||||
const after = readFileSync(sourceFile, 'utf-8');
|
||||
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
|
||||
if (svelteComponentSession) {
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte');
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantBody = readFileSync(variantFile, 'utf-8');
|
||||
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted');
|
||||
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
|
||||
if (isInsert) {
|
||||
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
|
||||
if (agentMode === 'fake') {
|
||||
@@ -328,9 +331,9 @@ for (const { name, fixture } of fixtures) {
|
||||
assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element');
|
||||
}
|
||||
} else {
|
||||
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element');
|
||||
assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'component variant contains target element');
|
||||
}
|
||||
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation');
|
||||
assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'route source is not edited during component preview');
|
||||
} else {
|
||||
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
|
||||
}
|
||||
@@ -349,7 +352,8 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
}
|
||||
if (svelteComponentSession) {
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /<style>/, 'Svelte component variant has scoped style block');
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
} else if (sourceFile.endsWith('.astro')) {
|
||||
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
|
||||
assert.match(
|
||||
@@ -376,6 +380,13 @@ for (const { name, fixture } of fixtures) {
|
||||
for (const kind of ['range', 'steps', 'toggle']) {
|
||||
assert.match(paramsSource, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
const tune = root.querySelector('[data-iceq-tune="1"]');
|
||||
return tune && tune.disabled === false && /Tune/.test(tune.textContent || '');
|
||||
}, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
// 6. Cycle variants. Most fixtures stop at variant 2; Svelte Insert
|
||||
@@ -649,6 +660,271 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
});
|
||||
|
||||
if (['vite8-react-plain', 'astro-vite7', 'nextjs-app-router', 'vite8-sveltekit', 'nuxt-vite7'].includes(name) && shouldRunScenario('progressive')) {
|
||||
it('reveals variant 1 safely while the remaining variants and params are pending', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
|
||||
const traceEvents = [];
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
progressive: true,
|
||||
progressiveDelayMs: 2500,
|
||||
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, tmp, consoleErrors, teardown } = session;
|
||||
let sourceFile = null;
|
||||
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
const originalCopy = await page.locator(pickSelector).innerText();
|
||||
await pickElement(page, pickSelector);
|
||||
await clickGo(page);
|
||||
|
||||
const partial = await waitForProgressiveReviewState(page, 3);
|
||||
assert.equal(partial.arrived, 1, 'exactly variant 1 is present during the progressive interval');
|
||||
assert.equal(partial.visible, 1, 'variant 1 is the visible review target');
|
||||
assert.equal(partial.copy, originalCopy, 'variant 1 preserves the picked copy');
|
||||
assert.notEqual(partial.acceptPointerEvents, 'none', 'Accept is available for the first reviewable variant');
|
||||
assert.notEqual(partial.discardPointerEvents, 'none', 'Discard can cancel unfinished generation');
|
||||
assert.equal(partial.hasParams, false, 'variant 1 has no eager parameter manifest');
|
||||
assert.equal(partial.tuneVisible, true, 'Tune stays visible while parameter generation is outstanding');
|
||||
assert.equal(partial.tuneDisabled, true, 'pending Tune is non-interactive until controls arrive');
|
||||
assert.match(partial.tuneTitle || '', /still being prepared/, 'pending Tune explains its loading state');
|
||||
assert.equal(partial.paramsPanelVisible, false, 'the Tune popover stays closed until parameter delivery');
|
||||
|
||||
sourceFile = await locateSessionFile(tmp);
|
||||
const isComponentPreview = sourceFile.endsWith('manifest.json');
|
||||
if (isComponentPreview) {
|
||||
const manifest = JSON.parse(readFileSync(sourceFile, 'utf-8'));
|
||||
sourceFile = join(tmp, manifest.sourceFile);
|
||||
const extension = manifest.componentExtension || 'svelte';
|
||||
assert.equal(existsSync(join(tmp, manifest.componentDir, `v1.${extension}`)), true, 'partial component preview contains variant 1');
|
||||
assert.equal(existsSync(join(tmp, manifest.componentDir, 'params.json')), false, 'partial component preview defers parameter manifests');
|
||||
} else {
|
||||
const partialSource = readFileSync(sourceFile, 'utf-8');
|
||||
assert.equal(countSourceVariants(partialSource), 1, 'partial source contains one reviewable variant');
|
||||
assert.doesNotMatch(partialSource, /data-impeccable-params=/, 'partial source defers parameter manifests');
|
||||
}
|
||||
|
||||
// Keyboard Accept must durably fence the worker before its delayed
|
||||
// second publication, then return the browser to picking without
|
||||
// waiting for variants the user no longer wants.
|
||||
const acceptClickedAt = Date.now();
|
||||
await clickAccept(page, { expectedVariant: 1 });
|
||||
await waitForBarHidden(page);
|
||||
await page.waitForFunction(
|
||||
() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING',
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
const automationAcceptToPickingMs = Date.now() - acceptClickedAt;
|
||||
const browserAcceptToPickingMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
|
||||
const acceptToPickingMs = Number.isFinite(browserAcceptToPickingMs) && browserAcceptToPickingMs > 0
|
||||
? browserAcceptToPickingMs
|
||||
: automationAcceptToPickingMs;
|
||||
t.diagnostic(`Accept dispatch → picker ready: ${acceptToPickingMs}ms (${automationAcceptToPickingMs}ms including Playwright actionability)`);
|
||||
assert.ok(acceptToPickingMs < 500, `Accept should release the picker within 500ms of dispatch; got ${acceptToPickingMs}ms`);
|
||||
const finalSource = await waitForSourceClean(sourceFile, 20_000);
|
||||
assert.match(finalSource, new RegExp(escapeRegExp(originalCopy)), 'early accepted source preserves the original copy');
|
||||
assert.doesNotMatch(finalSource, /data-impeccable-variant=/, 'early accepted source is free of preview scaffolding');
|
||||
assert.equal(countSourceVariants(finalSource), 0, 'the delayed worker cannot reinsert later variants');
|
||||
|
||||
const firstGenerateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
|
||||
// Give framework HMR one paint to settle the newly committed tree;
|
||||
// this stays inside the 1.5s next-pick budget and avoids selecting a
|
||||
// node instance React is replacing in the same frame.
|
||||
if (name === 'nextjs-app-router' || name === 'vite8-sveltekit' || name === 'nuxt-vite7') await waitForHandshake(page);
|
||||
await page.waitForTimeout(250);
|
||||
await page.mouse.move(1, 1);
|
||||
const nextPickSelector = name === 'nextjs-app-router'
|
||||
? 'main.page'
|
||||
: name === 'vite8-sveltekit'
|
||||
? 'article.feature-card'
|
||||
: name === 'nuxt-vite7'
|
||||
? 'main.page'
|
||||
: '.hero-hook';
|
||||
await pickElement(page, nextPickSelector, {
|
||||
resetPickMode: name === 'nextjs-app-router' || name === 'nuxt-vite7',
|
||||
position: name === 'nuxt-vite7' ? { x: 12, y: 12 } : undefined,
|
||||
});
|
||||
const nextGoAt = Date.now();
|
||||
await clickGo(page);
|
||||
let nextGenerateTrace = null;
|
||||
const pickupDeadline = Date.now() + 1_500;
|
||||
while (Date.now() < pickupDeadline) {
|
||||
nextGenerateTrace = traceEvents.find((event) => (
|
||||
event.name === 'agent.event.received'
|
||||
&& event.type === 'generate'
|
||||
&& event.id !== firstGenerateId
|
||||
));
|
||||
if (nextGenerateTrace) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.ok(nextGenerateTrace, 'the poll supervisor picks up the next generation while the canceled worker unwinds');
|
||||
const nextDispatchToPickupMs = nextGenerateTrace.at - nextGenerateTrace.clientSentAt;
|
||||
assert.ok(
|
||||
nextDispatchToPickupMs < 1_500,
|
||||
`next generation pickup should stay below 1.5s from dispatch; got ${nextDispatchToPickupMs}ms`,
|
||||
);
|
||||
t.diagnostic(`Next Go dispatch → generation pickup: ${nextDispatchToPickupMs}ms (${nextGenerateTrace.at - nextGoAt}ms including Playwright actionability)`);
|
||||
if (process.env.IMPECCABLE_E2E_METRICS_FILE) {
|
||||
appendFileSync(process.env.IMPECCABLE_E2E_METRICS_FILE, JSON.stringify({
|
||||
acceptToPickingMs,
|
||||
nextGoToPickupMs: nextDispatchToPickupMs,
|
||||
automationAcceptToPickingMs,
|
||||
automationNextGoToPickupMs: nextGenerateTrace.at - nextGoAt,
|
||||
fixture: name,
|
||||
at: new Date().toISOString(),
|
||||
}) + '\n');
|
||||
}
|
||||
assert.ok(
|
||||
traceEvents.some((event) => event.name === 'agent.scaffold.reused'),
|
||||
'agent reuses the server preflight scaffold',
|
||||
);
|
||||
assert.equal(
|
||||
traceEvents.some((event) => event.name === 'agent.scaffold.start'),
|
||||
false,
|
||||
'agent does not repeat deterministic source discovery after preflight',
|
||||
);
|
||||
const generateTrace = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate');
|
||||
assert.ok(generateTrace?.id, 'generate trace exposes the durable session id');
|
||||
const generationTimings = await waitForGenerationTimings(tmp, generateTrace.id, { requireAllVariants: false });
|
||||
assert.ok(generationTimings.generation_ready?.at, 'durable timing records when generation work can start');
|
||||
assert.ok(generationTimings.first_reviewable?.at, 'durable timing records the first reviewable variant');
|
||||
assert.equal(generationTimings.all_variants_ready, undefined, 'canceled work never records all variants ready');
|
||||
|
||||
const realErrors = consoleErrors.filter((error) =>
|
||||
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
|
||||
);
|
||||
if (fixture.runtime.probe?.expectConsoleClean) {
|
||||
assert.deepEqual(realErrors, [], 'progressive HMR and early-action guards produce no browser errors');
|
||||
} else if (realErrors.length > 0) {
|
||||
t.diagnostic(`Known framework HMR console noise during progressive source rewrites: ${realErrors.length} error(s)`);
|
||||
for (const error of realErrors) t.diagnostic(error.split('\n')[0]);
|
||||
}
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'vite8-react-plain' && shouldRunScenario('progressive')) {
|
||||
it('accepts variant 2 while variant 3 is still pending', liveE2eTestOptions, async (t) => {
|
||||
const traceEvents = [];
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
progressive: true,
|
||||
progressiveInitialCount: 2,
|
||||
progressiveDelayMs: 2500,
|
||||
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, tmp, consoleErrors, teardown } = session;
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
const originalCopy = await page.locator(pickSelector).innerText();
|
||||
await pickElement(page, pickSelector);
|
||||
await clickGo(page);
|
||||
|
||||
const partial = await waitForProgressiveReviewState(page, 3, { arrived: 2, visible: 1 });
|
||||
assert.equal(partial.arrived, 2, 'variants 1 and 2 arrive before variant 3');
|
||||
assert.equal(partial.visible, 1, 'variant 1 remains visible until the user advances');
|
||||
assert.notEqual(partial.acceptPointerEvents, 'none', 'arrived variants remain actionable while the tail is pending');
|
||||
assert.equal(partial.hasParams, false, 'the partial two-variant revision still defers parameter manifests');
|
||||
|
||||
await clickNext(page);
|
||||
const second = await readProgressiveReviewState(page);
|
||||
assert.equal(second.visible, 2, 'variant 2 is reviewable before variant 3 exists');
|
||||
assert.equal(second.copy, originalCopy, 'variant 2 preserves the picked copy');
|
||||
|
||||
const wrappedSource = await locateSessionFile(tmp);
|
||||
const acceptStartedAt = Date.now();
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
await waitForBarHidden(page);
|
||||
await page.waitForFunction(
|
||||
() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING',
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
const browserAcceptMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
|
||||
const acceptToPickingMs = Number.isFinite(browserAcceptMs) && browserAcceptMs > 0
|
||||
? browserAcceptMs
|
||||
: Date.now() - acceptStartedAt;
|
||||
assert.ok(acceptToPickingMs < 500, `variant 2 Accept should release the picker within 500ms; got ${acceptToPickingMs}ms`);
|
||||
|
||||
const cleanSource = await waitForSourceClean(wrappedSource, 20_000);
|
||||
assert.match(cleanSource, new RegExp(escapeRegExp(originalCopy)), 'accepted variant 2 preserves source copy');
|
||||
assert.doesNotMatch(cleanSource, /data-impeccable-variant=/, 'accepted variant 2 leaves no preview scaffolding');
|
||||
await page.waitForTimeout(2750);
|
||||
assert.doesNotMatch(readFileSync(wrappedSource, 'utf-8'), /data-impeccable-variant=/, 'the delayed variant 3 write stays fenced');
|
||||
|
||||
const generateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
|
||||
const timings = await waitForGenerationTimings(tmp, generateId, { requireAllVariants: false });
|
||||
assert.equal(timings.all_variants_ready, undefined, 'accepting variant 2 cancels the unfinished third variant');
|
||||
const realErrors = consoleErrors.filter((error) =>
|
||||
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
|
||||
);
|
||||
assert.deepEqual(realErrors, [], 'variant 2 early Accept stays console-clean');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
|
||||
it('promotes pending Tune controls when the params-only revision arrives', liveE2eTestOptions, async (t) => {
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
progressive: true,
|
||||
progressiveDelayMs: 1500,
|
||||
log: (message) => t.diagnostic(message),
|
||||
});
|
||||
const { page, teardown } = session;
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title');
|
||||
await clickGo(page);
|
||||
|
||||
const pending = await waitForProgressiveReviewState(page, 3);
|
||||
assert.equal(pending.tuneVisible, true);
|
||||
assert.equal(pending.tuneDisabled, true);
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
const tune = root.querySelector('[data-iceq-tune="1"]');
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
return tune?.disabled === false
|
||||
&& !!wrapper?.querySelector('[data-impeccable-params]');
|
||||
}, { timeout: 10_000 });
|
||||
const ready = await readProgressiveReviewState(page);
|
||||
assert.equal(ready.arrived, 3, 'all variants remain mounted after params publication');
|
||||
assert.equal(ready.tuneVisible, true);
|
||||
assert.equal(ready.tuneDisabled, false, 'Tune becomes actionable without another variant arrival');
|
||||
|
||||
await clickDiscard(page);
|
||||
await page.waitForFunction(() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING', { timeout: 2_000 });
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
|
||||
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
|
||||
for (const scenario of fixture.runtime.manualEditScenarios) {
|
||||
@@ -798,6 +1074,94 @@ function recordGenerateEvents(agent, events) {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForProgressiveReviewState(page, expected, { arrived: targetArrived = 1, visible: targetVisible = 1 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(({ variantCount, targetArrived, targetVisible }) => {
|
||||
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
|
||||
const wrapper = query('[data-impeccable-variants]');
|
||||
const variants = wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
|
||||
? Number(debugState?.arrivedVariants || 0)
|
||||
: variants?.length;
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
const bar = root.querySelector('#impeccable-live-bar');
|
||||
return arrived === targetArrived
|
||||
&& new RegExp(`${targetVisible}\\s*\\/\\s*${variantCount}`).test(bar?.textContent || '')
|
||||
&& /more arriving/.test(bar?.textContent || '');
|
||||
}, { variantCount: expected, targetArrived, targetVisible }, { timeout: 15_000 });
|
||||
return readProgressiveReviewState(page);
|
||||
}
|
||||
|
||||
async function readProgressiveReviewState(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
return page.evaluate(() => {
|
||||
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
|
||||
const wrapper = query('[data-impeccable-variants]');
|
||||
const variants = [...(wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])') || [])];
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
const isSveltePreview = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '');
|
||||
const visibleVariant = variants.find((variant) => getComputedStyle(variant).display !== 'none');
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|
||||
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| document;
|
||||
const buttons = [...root.querySelectorAll('#impeccable-live-bar button')];
|
||||
const accept = buttons.find((button) => /Accept/.test(button.textContent || ''));
|
||||
const discard = buttons.find((button) => (button.textContent || '').includes('✕'));
|
||||
const paramsPanel = root.querySelector('#impeccable-live-params-panel');
|
||||
const tune = root.querySelector('[data-iceq-tune="1"]');
|
||||
return {
|
||||
arrived: isSveltePreview ? Number(debugState?.arrivedVariants || 0) : variants.length,
|
||||
visible: isSveltePreview ? Number(debugState?.visibleVariant || 0) : Number(visibleVariant?.dataset.impeccableVariant || 0),
|
||||
copy: isSveltePreview ? (wrapper?.innerText || '') : (visibleVariant?.innerText || ''),
|
||||
acceptPointerEvents: accept ? getComputedStyle(accept).pointerEvents : null,
|
||||
discardPointerEvents: discard ? getComputedStyle(discard).pointerEvents : null,
|
||||
hasParams: variants.some((variant) => variant.hasAttribute('data-impeccable-params')),
|
||||
tuneVisible: !!tune,
|
||||
tuneDisabled: tune?.disabled ?? null,
|
||||
tuneTitle: tune?.title || '',
|
||||
paramsPanelVisible: !!paramsPanel
|
||||
&& getComputedStyle(paramsPanel).pointerEvents !== 'none'
|
||||
&& getComputedStyle(paramsPanel).clipPath === 'inset(0px)',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function countSourceVariants(source) {
|
||||
return (String(source).match(/<div\s+data-impeccable-variant="(?!original")/g) || []).length;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function waitForGenerationTimings(tmp, id, { timeoutMs = 5_000, requireAllVariants = true } = {}) {
|
||||
const snapshotPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.snapshot.json`);
|
||||
const journalPath = join(tmp, '.impeccable', 'live', 'sessions', `${id}.jsonl`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastTimings = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(snapshotPath)) {
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
const timings = snapshot.generationTimings || {};
|
||||
lastTimings = timings;
|
||||
if (timings.generation_ready && timings.first_reviewable && (!requireAllVariants || timings.all_variants_ready)) return timings;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const checkpointReasons = existsSync(journalPath)
|
||||
? readFileSync(journalPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)?.event)
|
||||
.filter((event) => event?.type === 'checkpoint')
|
||||
.map((event) => ({ reason: event.reason, arrivedVariants: event.arrivedVariants, expectedVariants: event.expectedVariants }))
|
||||
: [];
|
||||
throw new Error(`generation timings did not complete for ${id}: timings=${JSON.stringify(lastTimings)} checkpoints=${JSON.stringify(checkpointReasons)}`);
|
||||
}
|
||||
|
||||
async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error, log = () => {} }) {
|
||||
const root = process.env.IMPECCABLE_E2E_ARTIFACT_DIR;
|
||||
if (!root || !session?.tmp) return;
|
||||
@@ -1453,11 +1817,12 @@ function svelteComponentTargetFor(filePath) {
|
||||
if (!filePath.endsWith('/manifest.json') && !filePath.endsWith('\\manifest.json')) return null;
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
|
||||
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
if (!['svelte-component', 'vue-component'].includes(manifest.previewMode) || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
const sep = pathSepFor(filePath);
|
||||
const markers = [
|
||||
`${sep}node_modules${sep}.impeccable-live${sep}`,
|
||||
`${sep}src${sep}lib${sep}impeccable${sep}`,
|
||||
`${sep}app${sep}.impeccable-live${sep}`,
|
||||
];
|
||||
const marker = markers.find((candidate) => filePath.includes(candidate));
|
||||
const idx = marker ? filePath.indexOf(marker) : -1;
|
||||
@@ -1547,18 +1912,19 @@ async function locateSessionFile(tmp) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
for (const f of walkSvelteComponentManifests(tmp)) {
|
||||
for (const f of walkComponentManifests(tmp)) {
|
||||
const body = readFileSync(f, 'utf-8');
|
||||
if (body.includes('"previewMode": "svelte-component"')) return f;
|
||||
if (/"previewMode": "(?:svelte|vue)-component"/.test(body)) return f;
|
||||
}
|
||||
throw new Error('Could not locate session source file under ' + tmp);
|
||||
}
|
||||
|
||||
function walkSvelteComponentManifests(root) {
|
||||
function walkComponentManifests(root) {
|
||||
const results = [];
|
||||
const stack = [
|
||||
join(root, 'node_modules/.impeccable-live'),
|
||||
join(root, 'src/lib/impeccable'),
|
||||
join(root, 'app/.impeccable-live'),
|
||||
];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
|
||||
+323
-11
@@ -27,6 +27,10 @@ import { join } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { completionTypeForAcceptResult } from '../../skill/scripts/live/completion.mjs';
|
||||
import {
|
||||
prepareGenerationArtifact,
|
||||
publishGenerationArtifact,
|
||||
} from '../../skill/scripts/live/generation-publisher.mjs';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
@@ -1325,15 +1329,25 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
styleMode: wrapInfo.styleMode,
|
||||
});
|
||||
|
||||
const endMarkerIdx = lines.findIndex((line, index) =>
|
||||
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
|
||||
);
|
||||
if (endMarkerIdx === -1) {
|
||||
throw new Error('end marker not found in ' + wrapInfo.file);
|
||||
}
|
||||
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
|
||||
? endMarkerIdx
|
||||
: endMarkerIdx - 1;
|
||||
|
||||
const next = [
|
||||
...lines.slice(0, markerIdx + 1),
|
||||
block,
|
||||
...lines.slice(markerIdx + 1),
|
||||
...lines.slice(tailIdx),
|
||||
];
|
||||
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
|
||||
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const manifestPath = path.join(tmp, wrapInfo.file);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
@@ -1373,7 +1387,168 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
if (writeParams) {
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
manifest.arrivedVariants = output.variants.length;
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const prepared = prepareGenerationArtifact({
|
||||
id: event.id,
|
||||
sourceFile: wrapInfo.file,
|
||||
cwd: tmp,
|
||||
});
|
||||
if (!prepared.ok) throw new Error(`Svelte publication prepare failed: ${prepared.error}`);
|
||||
|
||||
await writeSvelteComponentVariants({
|
||||
tmp,
|
||||
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
|
||||
event,
|
||||
output,
|
||||
writeParams,
|
||||
});
|
||||
|
||||
const published = publishGenerationArtifact({
|
||||
id: event.id,
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: wrapInfo.file,
|
||||
artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: output.variants.length,
|
||||
expectedVariants: event.count,
|
||||
cwd: tmp,
|
||||
});
|
||||
if (!published.ok) throw new Error(`Svelte publication failed: ${published.error}`);
|
||||
return published;
|
||||
}
|
||||
|
||||
async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const manifestPath = path.join(tmp, wrapInfo.file);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
|
||||
const textValues = extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
|
||||
const paramsByVariant = {};
|
||||
|
||||
for (let i = 0; i < output.variants.length; i++) {
|
||||
const variantId = i + 1;
|
||||
const variant = output.variants[i];
|
||||
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
|
||||
for (const entry of contract) {
|
||||
markup = markup.replaceAll(`{${entry.prop}}`, `{{ ${entry.prop} }}`);
|
||||
}
|
||||
const css = svelteCssForVariant(output.scopedCss || '', variantId, firstTagName(markup) || 'div');
|
||||
const propsScript = contract.length > 0
|
||||
? ['<script setup>', 'defineProps({', ...contract.map((entry) => ` ${entry.prop}: { default: '' },`), '});', '</script>', '']
|
||||
: [];
|
||||
const component = [
|
||||
...propsScript,
|
||||
'<template>',
|
||||
markup || '<div></div>',
|
||||
'</template>',
|
||||
'',
|
||||
'<style scoped>',
|
||||
css || ':where(*) {}',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(componentDir, `v${variantId}.vue`), component, 'utf-8');
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
}
|
||||
|
||||
if (writeParams) {
|
||||
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
manifest.arrivedVariants = output.variants.length;
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
|
||||
const prepared = prepareGenerationArtifact({ id: event.id, sourceFile: wrapInfo.file, cwd: tmp });
|
||||
if (!prepared.ok) throw new Error(`Vue publication prepare failed: ${prepared.error}`);
|
||||
await writeVueComponentVariants({
|
||||
tmp,
|
||||
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
|
||||
event,
|
||||
output,
|
||||
writeParams,
|
||||
});
|
||||
const published = publishGenerationArtifact({
|
||||
id: event.id,
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: wrapInfo.file,
|
||||
artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: output.variants.length,
|
||||
expectedVariants: event.count,
|
||||
cwd: tmp,
|
||||
});
|
||||
if (!published.ok) throw new Error(`Vue publication failed: ${published.error}`);
|
||||
return published;
|
||||
}
|
||||
|
||||
async function publishSourceVariants({ tmp, wrapInfo, event, output }) {
|
||||
const prepared = prepareGenerationArtifact({
|
||||
id: event.id,
|
||||
sourceFile: wrapInfo.file,
|
||||
cwd: tmp,
|
||||
});
|
||||
if (!prepared.ok) throw new Error(`Source publication prepare failed: ${prepared.error}`);
|
||||
|
||||
await spliceVariantsIntoWrapper({
|
||||
tmp,
|
||||
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
|
||||
sessionId: event.id,
|
||||
output,
|
||||
});
|
||||
|
||||
const published = publishGenerationArtifact({
|
||||
id: event.id,
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: wrapInfo.file,
|
||||
artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: output.variants.length,
|
||||
expectedVariants: event.count,
|
||||
cwd: tmp,
|
||||
});
|
||||
if (!published.ok) throw new Error(`Source publication failed: ${published.error}`);
|
||||
return published;
|
||||
}
|
||||
|
||||
async function publishVariantProgress({
|
||||
base,
|
||||
token,
|
||||
event,
|
||||
wrapInfo,
|
||||
arrivedVariants,
|
||||
signal,
|
||||
revision = 1,
|
||||
publicationKind = 'variants',
|
||||
}) {
|
||||
const previewMode = wrapInfo.previewMode || 'source';
|
||||
await fetch(`${base}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: 'checkpoint',
|
||||
id: event.id,
|
||||
revision,
|
||||
revisionDomain: 'publication',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
arrivedVariants,
|
||||
expectedVariants: event.count,
|
||||
sourceFile: wrapInfo.sourceFile || wrapInfo.file,
|
||||
previewFile: wrapInfo.file,
|
||||
previewMode,
|
||||
publicationKind,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
function variantMarkupHasVisibleContent(markup) {
|
||||
@@ -1507,6 +1682,11 @@ export async function runAgentLoop({
|
||||
agent,
|
||||
signal,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
progressive = false,
|
||||
progressiveDelayMs = 0,
|
||||
progressiveInitialCount = 1,
|
||||
atomicDelayMs = 0,
|
||||
wrapTarget = { classes: 'hero-title', tag: 'h1' },
|
||||
steerSourceFile,
|
||||
steerTarget,
|
||||
@@ -1530,6 +1710,8 @@ export async function runAgentLoop({
|
||||
if (event.type === 'prefetch') continue;
|
||||
if (event.type === 'connected') continue;
|
||||
|
||||
trace('agent.event.received', { id: event.id, type: event.type, clientSentAt: event.clientSentAt ?? null });
|
||||
|
||||
if (event.type === 'steer') {
|
||||
log(`steer id=${event.id} message=${JSON.stringify(event.message)}`);
|
||||
try {
|
||||
@@ -1578,7 +1760,16 @@ export async function runAgentLoop({
|
||||
log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`);
|
||||
try {
|
||||
let wrapInfo;
|
||||
if (isInsert) {
|
||||
if (event.scaffold) {
|
||||
wrapInfo = event.scaffold;
|
||||
trace('agent.scaffold.reused', {
|
||||
id: event.id,
|
||||
file: wrapInfo.file,
|
||||
previewMode: wrapInfo.previewMode || 'source',
|
||||
durationMs: event.scaffoldDurationMs ?? null,
|
||||
});
|
||||
} else if (isInsert) {
|
||||
trace('agent.scaffold.start', { id: event.id, mode: 'insert' });
|
||||
const insertTarget = insertTargetFromEvent(event);
|
||||
wrapInfo = await runInsert({
|
||||
tmp,
|
||||
@@ -1587,7 +1778,9 @@ export async function runAgentLoop({
|
||||
count: event.count,
|
||||
...insertTarget,
|
||||
});
|
||||
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
|
||||
} else {
|
||||
trace('agent.scaffold.start', { id: event.id, mode: 'replace' });
|
||||
// 1. Wrap the original element in the variant scaffold (deterministic CLI)
|
||||
// wrapTarget can be a static {classes, tag, elementId} (test fixtures
|
||||
// know what they pick) or a function (event) => target (real-use
|
||||
@@ -1606,41 +1799,154 @@ export async function runAgentLoop({
|
||||
...target,
|
||||
text,
|
||||
});
|
||||
trace('agent.scaffold.end', { id: event.id, file: wrapInfo.file, previewMode: wrapInfo.previewMode || 'source' });
|
||||
}
|
||||
log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
|
||||
|
||||
// 2. Agent generates variant content (LLM-pluggable seam)
|
||||
let output = await agent.generateVariants(event, { wrapTarget, wrapInfo });
|
||||
output = normalizeVariantOutput(output, wrapInfo);
|
||||
// 2. Agent generates variant content (LLM-pluggable seam).
|
||||
// Providers may expose a true split path so variant 1 is written before
|
||||
// the request for the remaining variants completes.
|
||||
trace('agent.generate.start', { id: event.id, count: event.count });
|
||||
const splitProgressive = progressive
|
||||
&& typeof agent.generateFirstVariant === 'function'
|
||||
&& typeof agent.generateRemainingVariants === 'function'
|
||||
&& event.count > 1;
|
||||
let output;
|
||||
let firstOutput;
|
||||
if (splitProgressive) {
|
||||
firstOutput = normalizeVariantOutput(
|
||||
await agent.generateFirstVariant(event, { wrapTarget, wrapInfo }),
|
||||
wrapInfo,
|
||||
);
|
||||
firstOutput = {
|
||||
...firstOutput,
|
||||
variants: firstOutput.variants.slice(0, 1).map((variant) => ({ ...variant, params: [] })),
|
||||
};
|
||||
trace('agent.generate.first_ready', { id: event.id, count: firstOutput.variants.length });
|
||||
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
|
||||
} else if (wrapInfo.previewMode === 'vue-component') {
|
||||
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
|
||||
} else {
|
||||
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
|
||||
}
|
||||
await publishVariantProgress({
|
||||
base,
|
||||
token,
|
||||
event,
|
||||
wrapInfo,
|
||||
arrivedVariants: firstOutput.variants.length,
|
||||
signal,
|
||||
});
|
||||
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
|
||||
output = normalizeVariantOutput(
|
||||
await agent.generateRemainingVariants(event, { wrapTarget, wrapInfo, firstOutput }),
|
||||
wrapInfo,
|
||||
);
|
||||
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
|
||||
} else {
|
||||
output = normalizeVariantOutput(
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
|
||||
wrapInfo,
|
||||
);
|
||||
if (!progressive && atomicDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
|
||||
}
|
||||
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
|
||||
if (!progressive || output.variants.length <= 1) {
|
||||
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
|
||||
}
|
||||
|
||||
if (progressive && output.variants.length > 1) {
|
||||
const initialCount = Math.max(1, Math.min(
|
||||
Number(progressiveInitialCount) || 1,
|
||||
output.variants.length - 1,
|
||||
));
|
||||
firstOutput = {
|
||||
...output,
|
||||
variants: output.variants
|
||||
.slice(0, initialCount)
|
||||
.map((variant) => ({ ...variant, params: [] })),
|
||||
};
|
||||
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
|
||||
} else if (wrapInfo.previewMode === 'vue-component') {
|
||||
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
|
||||
} else {
|
||||
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
|
||||
}
|
||||
await publishVariantProgress({
|
||||
base,
|
||||
token,
|
||||
event,
|
||||
wrapInfo,
|
||||
arrivedVariants: firstOutput.variants.length,
|
||||
signal,
|
||||
});
|
||||
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
|
||||
if (progressiveDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, progressiveDelayMs));
|
||||
}
|
||||
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
|
||||
}
|
||||
}
|
||||
if (output.variants.length !== event.count) {
|
||||
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
|
||||
}
|
||||
|
||||
// 3. Write variants into the deterministic preview target.
|
||||
// 3. Write the complete set into the deterministic preview target.
|
||||
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output });
|
||||
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} else if (wrapInfo.previewMode === 'vue-component') {
|
||||
await publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} else if (progressive) {
|
||||
await publishSourceVariants({ tmp, wrapInfo, event, output });
|
||||
} else {
|
||||
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
}
|
||||
trace('agent.write.end', { id: event.id, file: wrapInfo.file });
|
||||
if (progressive) {
|
||||
await publishVariantProgress({
|
||||
base,
|
||||
token,
|
||||
event,
|
||||
wrapInfo,
|
||||
arrivedVariants: output.variants.length,
|
||||
signal,
|
||||
revision: 2,
|
||||
publicationKind: 'params',
|
||||
});
|
||||
}
|
||||
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
||||
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
|
||||
log(`--- post-splice (variants written) ---\n${post}`);
|
||||
}
|
||||
|
||||
// 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING)
|
||||
trace('agent.reply.start', { id: event.id });
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, type: 'done', id: event.id, file: wrapInfo.file }),
|
||||
body: JSON.stringify({ token, type: 'done', sourceEventType: 'generate', id: event.id, file: wrapInfo.file }),
|
||||
signal,
|
||||
});
|
||||
trace('agent.reply.end', { id: event.id });
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
if (isExpectedGenerationCancellation(err)) {
|
||||
trace('agent.generate.canceled', { id: event.id, reason: 'stale_generation_epoch' });
|
||||
log('generate canceled after Accept/Discard: ' + err.message);
|
||||
continue;
|
||||
}
|
||||
trace('agent.generate.error', { id: event.id, message: err.message });
|
||||
log('generate failed: ' + err.message);
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }),
|
||||
body: JSON.stringify({ token, type: 'error', sourceEventType: 'generate', id: event.id, message: err.message }),
|
||||
signal,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1740,6 +2046,7 @@ export async function runAgentLoop({
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: completionType,
|
||||
sourceEventType: 'accept',
|
||||
id: event.id,
|
||||
file: acceptResult.file,
|
||||
message: acceptResult.error,
|
||||
@@ -1769,6 +2076,7 @@ export async function runAgentLoop({
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: completionType,
|
||||
sourceEventType: 'discard',
|
||||
id: event.id,
|
||||
file: discardResult.file,
|
||||
message: discardResult.error,
|
||||
@@ -1787,6 +2095,10 @@ export async function runAgentLoop({
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedGenerationCancellation(error) {
|
||||
return /(?:^|\b)stale_generation_epoch(?:\b|$)/.test(String(error?.message || error || ''));
|
||||
}
|
||||
|
||||
async function runPollReply({ tmp, scriptsDir, id, status, message, data }) {
|
||||
const args = [path.join(scriptsDir, 'live-poll.mjs'), '--reply', id, status];
|
||||
if (data !== undefined) args.push('--data', JSON.stringify(data));
|
||||
|
||||
@@ -192,6 +192,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [
|
||||
* @property {string=} model Override the selected provider's default model.
|
||||
* @property {string=} baseURL Override the provider API base URL.
|
||||
* @property {object=} config Pre-resolved provider config from resolveLlmAgentConfig().
|
||||
* @property {boolean=} includeLiveSpec Attach the full live.md reference. Defaults to true; latency benchmarks disable it to export only the synthetic element contract.
|
||||
* @property {(msg: string) => void=} log Optional logger for debug output.
|
||||
*/
|
||||
|
||||
@@ -240,14 +241,22 @@ export async function createLlmAgent(opts = {}) {
|
||||
const { apiKey, baseURL, model, provider } = config;
|
||||
const log = opts.log || (() => {});
|
||||
|
||||
const liveMd = await fs.readFile(LIVE_MD_PATH, 'utf-8');
|
||||
const liveMd = opts.includeLiveSpec === false ? null : await fs.readFile(LIVE_MD_PATH, 'utf-8');
|
||||
const client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
const systemBlocks = (instructions) => [
|
||||
{
|
||||
type: 'text',
|
||||
text: liveMd ? instructions : instructions.replace(/\n\nCONTEXT —[^\n]+$/, ''),
|
||||
},
|
||||
...(liveMd ? [{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } }] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
async generateVariants(event, context = {}) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const baseUserMessage = [
|
||||
`Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`,
|
||||
progressiveVariantGuidance(event),
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(buildVariantRequestPayload(event, context), null, 2),
|
||||
@@ -256,6 +265,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
|
||||
let userMessage = baseUserMessage;
|
||||
for (let attempt = 0; attempt < MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const lastAttempt = attempt + 1 >= MANUAL_EDIT_RESPONSE_MAX_ATTEMPTS;
|
||||
let response;
|
||||
try {
|
||||
response = await client.messages.create(
|
||||
@@ -263,15 +273,10 @@ export async function createLlmAgent(opts = {}) {
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens: 16000,
|
||||
system: [
|
||||
{ type: 'text', text: VARIANT_SYSTEM_INSTRUCTIONS },
|
||||
// Cacheable: the entire stable prefix (instructions + spec) is
|
||||
// cached up to this breakpoint. The user message holds all the
|
||||
// per-call volatile content. DeepSeek compatibility support is
|
||||
// provider-reported and best-effort; the usage log below tells us
|
||||
// whether cache reads/writes actually happened.
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
// When present, live.md is the final cacheable stable prefix.
|
||||
// Benchmarks omit it so external payloads contain only the
|
||||
// synthetic element contract and per-run event.
|
||||
system: systemBlocks(VARIANT_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
{
|
||||
@@ -280,7 +285,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (attempt === 1) throw err;
|
||||
if (lastAttempt) throw err;
|
||||
log(`variant request failed; retrying: ${err.message}`);
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -300,7 +305,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
`provider=${provider} model=${model} attempt=${attempt + 1} input=${inputTokens} output=${outputTokens} cache_read=${cacheRead} cache_write=${cacheWrite}`,
|
||||
);
|
||||
if (!response || !Array.isArray(response.content)) {
|
||||
if (attempt === 1) throw new Error('LLM agent: provider returned an empty variant response');
|
||||
if (lastAttempt) throw new Error('LLM agent: provider returned an empty variant response');
|
||||
log('variant response validation failed; retrying: provider returned an empty response');
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -320,7 +325,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
try {
|
||||
parsed = parseVariantResponse(text);
|
||||
} catch (err) {
|
||||
if (attempt === 1) throw err;
|
||||
if (lastAttempt) throw err;
|
||||
log(`variant response validation failed; retrying: ${err.message.split('\n')[0]}`);
|
||||
userMessage = [
|
||||
baseUserMessage,
|
||||
@@ -332,11 +337,13 @@ export async function createLlmAgent(opts = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationError = isInsert
|
||||
? validateInsertVariantOutput(parsed, event)
|
||||
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element));
|
||||
const validationError = validateVariantCount(parsed, event)
|
||||
|| validateProgressiveVariantOutput(parsed, event)
|
||||
|| (isInsert
|
||||
? validateInsertVariantOutput(parsed, event)
|
||||
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element)));
|
||||
if (!validationError) return parsed;
|
||||
if (attempt === 1) throw new Error(`LLM agent: ${validationError}`);
|
||||
if (lastAttempt) throw new Error(`LLM agent: ${validationError}`);
|
||||
|
||||
log(`variant validation failed; retrying: ${validationError}`);
|
||||
if (isInsert) {
|
||||
@@ -411,10 +418,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
model,
|
||||
temperature: 0,
|
||||
max_tokens: 16000,
|
||||
system: [
|
||||
{ type: 'text', text: MANUAL_EDIT_SYSTEM_INSTRUCTIONS },
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
system: systemBlocks(MANUAL_EDIT_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
{
|
||||
@@ -542,10 +546,7 @@ export async function createLlmAgent(opts = {}) {
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{ type: 'text', text: STEER_SYSTEM_INSTRUCTIONS },
|
||||
{ type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
|
||||
@@ -672,6 +673,7 @@ export function buildVariantRequestPayload(event, context = {}) {
|
||||
action: event?.action,
|
||||
freeformPrompt: event?.freeformPrompt,
|
||||
count: event?.count,
|
||||
progressive: event?.progressive,
|
||||
element: isInsert ? null : {
|
||||
outerHTML: event?.element?.outerHTML,
|
||||
tagName: event?.element?.tagName,
|
||||
@@ -691,6 +693,31 @@ export function buildVariantRequestPayload(event, context = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function progressiveVariantGuidance(event = {}) {
|
||||
if (event.progressive?.phase === 'first') {
|
||||
return [
|
||||
'PROGRESSIVE FIRST DELIVERY:',
|
||||
`- Return exactly ${event.count} variant now.`,
|
||||
'- Return params: [] for this variant; tunable parameters are generated in the final phase.',
|
||||
'- The innerHtml must be materially different from the picked source, not merely paired with different CSS.',
|
||||
'- For a bare-text element, preserve the full exact copy in one child span inside the unchanged root tag/class.',
|
||||
].join('\n');
|
||||
}
|
||||
if (event.progressive?.phase === 'remaining') {
|
||||
return [
|
||||
'PROGRESSIVE FINAL DELIVERY:',
|
||||
`- Return the complete final set of exactly ${event.count} variants, including variant 1.`,
|
||||
'- progressive.firstVariant is the already-visible variant 1. Keep its innerHtml exactly unchanged and add its deferred params now.',
|
||||
...(event.progressive.omitFirstVariantCss ? [
|
||||
'- Variant 1 CSS is already published and immutable. Do not repeat or modify any scopedCss rule for data-impeccable-variant="1"; return scopedCss rules for variants 2+ only.',
|
||||
] : []),
|
||||
'- Generate the remaining distinct variants and their params in the other array positions.',
|
||||
'- Every remaining variant innerHtml must be materially changed too; for bare text, wrap the full exact copy in one child span with a distinct class instead of relying on CSS alone.',
|
||||
].join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a model response into the variant-output schema. Throws
|
||||
* with a `Parsed (first 500 chars): ...` echo on every schema failure so the
|
||||
@@ -850,6 +877,30 @@ export function validateInsertVariantOutput(parsed, event = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateVariantCount(parsed, event = {}) {
|
||||
const expected = Number(event.count);
|
||||
if (!Number.isInteger(expected) || expected < 1) return 'event count must be a positive integer';
|
||||
const actual = Array.isArray(parsed?.variants) ? parsed.variants.length : 0;
|
||||
return actual === expected ? null : `expected exactly ${expected} variants, received ${actual}`;
|
||||
}
|
||||
|
||||
export function validateProgressiveVariantOutput(parsed, event = {}) {
|
||||
if (event.progressive?.phase === 'first') {
|
||||
const hasEarlyParams = (parsed.variants || []).some((variant) => Array.isArray(variant.params) && variant.params.length > 0);
|
||||
return hasEarlyParams ? 'progressive first delivery must defer params with an empty params array' : null;
|
||||
}
|
||||
if (event.progressive?.phase === 'remaining' && event.progressive.firstVariant?.innerHtml) {
|
||||
const expected = String(event.progressive.firstVariant.innerHtml).trim();
|
||||
const actual = String(parsed.variants?.[0]?.innerHtml || '').trim();
|
||||
if (actual !== expected) return 'progressive final delivery must preserve variant 1 innerHtml exactly';
|
||||
if (event.progressive.omitFirstVariantCss && /\[data-impeccable-variant\s*=\s*["']1["'][^\]]*\]/.test(parsed.scopedCss || '')) {
|
||||
return 'progressive final delivery must omit already-published variant 1 CSS';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateVariantMaterialChange(parsed, element) {
|
||||
const originalHtml = normalizeVariantHtml(element?.outerHTML || '');
|
||||
if (!originalHtml) return null;
|
||||
|
||||
+92
-20
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -32,8 +32,7 @@ export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
|
||||
// Stage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function stageFixture(name, fixture) {
|
||||
const fixtureRoot = join(FIXTURES_DIR, name);
|
||||
export function stageFixture(name, fixture, { fixtureRoot = join(FIXTURES_DIR, name) } = {}) {
|
||||
const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
|
||||
@@ -56,6 +55,7 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
|
||||
const installArgs = addNpmInstallDefaults(cmd, args);
|
||||
try {
|
||||
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
||||
repairMissingRollupOptionalBinary(tmp, { timeoutMs });
|
||||
} catch (err) {
|
||||
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
|
||||
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
|
||||
@@ -64,11 +64,26 @@ export function runInstall(tmp, command, { timeoutMs = readTimeoutEnv('IMPECCABL
|
||||
}
|
||||
}
|
||||
|
||||
function repairMissingRollupOptionalBinary(tmp, { timeoutMs }) {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') return;
|
||||
const rollupPackage = join(tmp, 'node_modules', 'rollup', 'package.json');
|
||||
const nativePackage = join(tmp, 'node_modules', '@rollup', 'rollup-darwin-arm64', 'package.json');
|
||||
if (!existsSync(rollupPackage) || existsSync(nativePackage)) return;
|
||||
const version = JSON.parse(readFileSync(rollupPackage, 'utf-8')).version;
|
||||
execFileSync('npm', [
|
||||
'install', '--no-save', '--no-audit', '--no-fund', '--no-progress',
|
||||
`@rollup/rollup-darwin-arm64@${version}`,
|
||||
], { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
||||
}
|
||||
|
||||
function addNpmInstallDefaults(cmd, args) {
|
||||
if (cmd !== 'npm') return args;
|
||||
if (!['install', 'ci'].includes(args[0])) return args;
|
||||
const out = [...args];
|
||||
for (const flag of ['--prefer-offline', '--no-progress']) {
|
||||
// npm can omit platform-specific Rollup binaries unless optional
|
||||
// dependencies are requested explicitly (npm/cli#4828). Astro/Vite then
|
||||
// fail before Live starts on fresh staged fixtures.
|
||||
for (const flag of ['--no-progress', '--include=optional']) {
|
||||
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
|
||||
}
|
||||
return out;
|
||||
@@ -200,29 +215,57 @@ export async function stopDevServer(child) {
|
||||
* @param {object} opts
|
||||
* @param {string} opts.name fixture name
|
||||
* @param {object} opts.fixture fixture.json contents
|
||||
* @param {string=} opts.fixtureRoot fixture directory; defaults to the public framework fixture tree
|
||||
* @param {import('playwright').Browser} opts.browser shared browser instance
|
||||
* @param {object} opts.agent VariantAgent (defaults to fake)
|
||||
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
|
||||
* @param {(context: object) => Promise<object|void>} [opts.startWorker]
|
||||
* Optional production worker factory. Return {stop, done}; when used,
|
||||
* omit `agent` so the deterministic in-process loop is not started.
|
||||
* @param {(context: object) => Promise<void>|void} [opts.prepareTmp]
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
*/
|
||||
export async function bootFixtureSession({ name, fixture, browser, agent, wrapTarget, log = () => {} }) {
|
||||
export async function bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
fixtureRoot,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget,
|
||||
startWorker,
|
||||
prepareTmp,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
progressive = false,
|
||||
progressiveDelayMs = 0,
|
||||
progressiveInitialCount = 1,
|
||||
atomicDelayMs = 0,
|
||||
keepTmp = false,
|
||||
}) {
|
||||
const runtime = fixture.runtime;
|
||||
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
|
||||
|
||||
const tmp = stageFixture(name, fixture);
|
||||
const tmp = stageFixture(name, fixture, { fixtureRoot });
|
||||
let live;
|
||||
let dev;
|
||||
let agentAbort;
|
||||
let agentDone;
|
||||
let externalWorker;
|
||||
let ctx;
|
||||
|
||||
const teardown = async () => {
|
||||
try { if (ctx) await ctx.close(); } catch {}
|
||||
try { if (agentAbort) agentAbort.abort(); } catch {}
|
||||
try { if (agentDone) await agentDone.catch(() => {}); } catch {}
|
||||
try { if (externalWorker?.stop) await externalWorker.stop(); } catch {}
|
||||
try { if (externalWorker?.done) await externalWorker.done.catch(() => {}); } catch {}
|
||||
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
|
||||
try { if (live) stopLiveServer(tmp); } catch {}
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
if (!keepTmp) {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
} else {
|
||||
log(`kept staged fixture at ${tmp}`);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveForDeferredWork = () => {
|
||||
@@ -233,41 +276,67 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
if (prepareTmp) await prepareTmp({ tmp, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
trace('setup.install.start', { fixture: name });
|
||||
log(`installing deps`);
|
||||
runInstall(tmp, runtime.install);
|
||||
trace('setup.install.end', { fixture: name });
|
||||
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
|
||||
|
||||
const liveStartedAt = Date.now();
|
||||
trace('setup.live_server.start', { fixture: name });
|
||||
log(`starting live-server`);
|
||||
live = startLiveServer(tmp);
|
||||
trace('setup.live_server.end', { fixture: name, port: live.port });
|
||||
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
|
||||
if (startWorker) {
|
||||
trace('setup.worker.start', { fixture: name });
|
||||
externalWorker = await startWorker({ tmp, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
trace('setup.worker.end', { fixture: name });
|
||||
}
|
||||
|
||||
const injectStartedAt = Date.now();
|
||||
trace('setup.inject.start', { fixture: name });
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port);
|
||||
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
|
||||
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
|
||||
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
|
||||
|
||||
const devStartedAt = Date.now();
|
||||
trace('setup.dev_server.start', { fixture: name });
|
||||
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
|
||||
dev = startDevServer(tmp, runtime);
|
||||
const { port: devPort } = await dev.ready;
|
||||
trace('setup.dev_server.end', { fixture: name, port: devPort });
|
||||
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
|
||||
|
||||
// Agent loop runs concurrently — abort on teardown.
|
||||
agentAbort = new AbortController();
|
||||
agentDone = runAgentLoop({
|
||||
tmp,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
agent,
|
||||
wrapTarget,
|
||||
signal: agentAbort.signal,
|
||||
log: (m) => log('[agent] ' + m),
|
||||
steerSourceFile: runtime.steer?.sourceFile,
|
||||
steerTarget: runtime.steer?.target,
|
||||
});
|
||||
if (agent) {
|
||||
agentAbort = new AbortController();
|
||||
const loopOptions = {
|
||||
tmp,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
agent,
|
||||
wrapTarget,
|
||||
signal: agentAbort.signal,
|
||||
trace,
|
||||
progressive,
|
||||
progressiveDelayMs,
|
||||
progressiveInitialCount,
|
||||
atomicDelayMs,
|
||||
steerSourceFile: runtime.steer?.sourceFile,
|
||||
steerTarget: runtime.steer?.target,
|
||||
};
|
||||
const loops = [runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })];
|
||||
if (progressive) {
|
||||
loops.push(runAgentLoop({ ...loopOptions, log: (m) => log('[supervisor] ' + m) }));
|
||||
}
|
||||
agentDone = Promise.all(loops);
|
||||
}
|
||||
|
||||
const scheme = runtime.scheme || 'http';
|
||||
ctx = await browser.newContext({
|
||||
@@ -283,10 +352,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
});
|
||||
|
||||
const pageStartedAt = Date.now();
|
||||
trace('setup.page_load.start', { fixture: name });
|
||||
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
trace('setup.page_load.end', { fixture: name });
|
||||
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
|
||||
|
||||
return {
|
||||
@@ -295,6 +366,7 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
|
||||
ctx,
|
||||
dev,
|
||||
live,
|
||||
worker: externalWorker,
|
||||
consoleErrors,
|
||||
stopLiveServer: stopLiveForDeferredWork,
|
||||
teardown,
|
||||
|
||||
+58
-4
@@ -424,7 +424,23 @@ export async function pickElement(page, selector, opts = {}) {
|
||||
if (visible) break;
|
||||
await resetPickMode(page);
|
||||
if (attempt === 2) {
|
||||
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
|
||||
const snapshot = await page.evaluate(({ selector, barSel, pickSel }) => {
|
||||
const target = document.querySelector(selector);
|
||||
const rect = target?.getBoundingClientRect();
|
||||
const hit = rect ? document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) : null;
|
||||
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
|
||||
const bar = query(barSel);
|
||||
const pick = query(pickSel);
|
||||
return {
|
||||
liveState: window.__IMPECCABLE_LIVE_STATE__ || null,
|
||||
target: target ? { tag: target.tagName, classes: target.className, rect: rect?.toJSON?.() || null } : null,
|
||||
hit: hit ? { tag: hit.tagName, classes: hit.className, text: (hit.textContent || '').slice(0, 80) } : null,
|
||||
pickActive: pick?.dataset.active || null,
|
||||
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
|
||||
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
|
||||
};
|
||||
}, { selector, barSel: BAR_ID, pickSel: PICK_TOGGLE_ID }).catch((error) => ({ error: error.message }));
|
||||
throw new Error(`pick did not open configure bar for ${selector}: ${JSON.stringify(snapshot)}`);
|
||||
}
|
||||
}
|
||||
// Wait specifically for the Configure-row submit button to be in the bar.
|
||||
@@ -528,6 +544,36 @@ export async function setCount(page, count) {
|
||||
throw new Error(`could not cycle count to ${count}`);
|
||||
}
|
||||
|
||||
/** Select a named Impeccable sub-command from the configure-row picker. */
|
||||
export async function selectAction(page, action) {
|
||||
const pickerSelector = '#impeccable-live-picker';
|
||||
const opened = await page.evaluate(({ barSel, pickerSel }) => {
|
||||
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
|
||||
const bar = query(barSel);
|
||||
const picker = query(pickerSel);
|
||||
const actionControl = [...(bar?.querySelectorAll('button') || [])]
|
||||
.find((button) => (button.textContent || '').includes('\u25BE'));
|
||||
if (!actionControl || !picker) return false;
|
||||
actionControl.click();
|
||||
return true;
|
||||
}, { barSel: BAR_ID, pickerSel: pickerSelector });
|
||||
if (!opened) throw new Error('could not open Live action picker');
|
||||
|
||||
await page.waitForFunction((selector) => {
|
||||
const picker = window.__impeccableLiveQuery(selector);
|
||||
return picker && picker.style.display !== 'none';
|
||||
}, pickerSelector, { timeout: 5_000 });
|
||||
|
||||
const selected = await page.evaluate(({ pickerSel, value }) => {
|
||||
const picker = window.__impeccableLiveQuery(pickerSel);
|
||||
const chip = picker?.querySelector(`button[data-action="${CSS.escape(value)}"]`);
|
||||
if (!chip) return false;
|
||||
chip.click();
|
||||
return true;
|
||||
}, { pickerSel: pickerSelector, value: action });
|
||||
if (!selected) throw new Error(`Live action ${JSON.stringify(action)} is unavailable`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Go. Browser POSTs the generate event; the agent picks it up. Headed
|
||||
* browser runs can occasionally accept the click without leaving configure
|
||||
@@ -578,7 +624,14 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
|
||||
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
|
||||
const m = text.match(/(\d+)\s*\/\s*(\d+)/);
|
||||
if (!m) return false;
|
||||
return parseInt(m[2], 10) === expected;
|
||||
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
|
||||
? Number(debugState?.arrivedVariants || 0)
|
||||
: wrapper
|
||||
? wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length
|
||||
: 0;
|
||||
return parseInt(m[2], 10) === expected && arrived >= expected;
|
||||
},
|
||||
{ barSel: BAR_ID, expected: expectedCount },
|
||||
{ timeout },
|
||||
@@ -590,7 +643,7 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
|
||||
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
|
||||
const bar = query(barSel);
|
||||
const toast = query('#impeccable-live-toast');
|
||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||||
const wrapper = query('[data-impeccable-variants]');
|
||||
return {
|
||||
liveInit: window.__IMPECCABLE_LIVE_INIT__,
|
||||
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
|
||||
@@ -751,7 +804,8 @@ async function ensureVisibleVariant(page, expectedVariant) {
|
||||
*/
|
||||
export async function clickDiscard(page) {
|
||||
// The discard button has just a "✕" glyph as text content.
|
||||
await page.locator(`${BAR_ID} button`, { hasText: '✕' }).click();
|
||||
if (await dispatchBarButton(page, '✕')) return;
|
||||
await clickBarButton(page, '✕');
|
||||
}
|
||||
|
||||
export async function clickEditCopy(page) {
|
||||
|
||||
@@ -97,3 +97,16 @@ describe('validateEvent — replace generate (regression)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — worker progress', () => {
|
||||
it('accepts bounded agent phases and rejects malformed telemetry', () => {
|
||||
assert.equal(validateEvent({
|
||||
type: 'agent_phase',
|
||||
id: VALID_ID,
|
||||
phase: 'first_variant_generating',
|
||||
durationMs: 123,
|
||||
}), null);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'Not valid' }), /phase/);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'valid', durationMs: -1 }), /durationMs/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildGenerationPreflight,
|
||||
runGenerationPreflight,
|
||||
} from '../skill/scripts/live/generation-preflight.mjs';
|
||||
|
||||
const SCRIPTS_DIR = path.resolve('skill/scripts');
|
||||
|
||||
test('builds a replace preflight from the picker locator', () => {
|
||||
const command = buildGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-1',
|
||||
count: 3,
|
||||
pageUrl: '/pricing',
|
||||
element: {
|
||||
id: 'hero',
|
||||
classes: ['hero', 'hero--dark'],
|
||||
tagName: 'SECTION',
|
||||
textContent: 'A faster way to ship',
|
||||
},
|
||||
}, SCRIPTS_DIR);
|
||||
|
||||
assert.equal(command.mode, 'replace');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-1', '--count', '3',
|
||||
'--element-id', 'hero',
|
||||
'--classes', 'hero hero--dark',
|
||||
'--tag', 'SECTION',
|
||||
'--text', 'A faster way to ship',
|
||||
'--page-url', '/pricing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds an insert preflight from the anchor locator', () => {
|
||||
const command = buildGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-2',
|
||||
count: 2,
|
||||
mode: 'insert',
|
||||
insert: {
|
||||
position: 'before',
|
||||
anchor: { classes: ['card'], tagName: 'ARTICLE', textContent: 'Plan' },
|
||||
},
|
||||
}, SCRIPTS_DIR);
|
||||
|
||||
assert.equal(command.mode, 'insert');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-2', '--count', '2', '--position', 'before',
|
||||
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns scaffold metadata without exposing child-process details', async () => {
|
||||
const calls = [];
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-3',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cwd: '/tmp/example',
|
||||
async execFileImpl(file, args, options) {
|
||||
calls.push({ file, args, options });
|
||||
return { stdout: '{"file":"src/App.jsx","insertLine":12}\n', stderr: '' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.scaffold, { file: 'src/App.jsx', insertLine: 12 });
|
||||
assert.equal(calls[0].file, process.execPath);
|
||||
assert.equal(calls[0].options.cwd, '/tmp/example');
|
||||
});
|
||||
|
||||
test('skips preflight when the picker has no source locator', async () => {
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-4',
|
||||
count: 3,
|
||||
element: { tagName: 'DIV' },
|
||||
}, { scriptsDir: SCRIPTS_DIR });
|
||||
|
||||
assert.deepEqual(result, { ok: false, skipped: true, reason: 'insufficient_locator' });
|
||||
});
|
||||
|
||||
test('yields to the event loop instead of blocking on the child process', async () => {
|
||||
// The server is single-threaded and leases polls through this call. A
|
||||
// synchronous spawn froze every other request (Accept, Discard, SSE) for the
|
||||
// scaffold's full duration — measured at ~7.6s on a large repo.
|
||||
let tickedDuringPreflight = false;
|
||||
const pending = runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-async',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
execFileImpl: () => new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ stdout: '{"file":"src/App.jsx"}\n', stderr: '' }), 25);
|
||||
}),
|
||||
});
|
||||
setTimeout(() => { tickedDuringPreflight = true; }, 5);
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(tickedDuringPreflight, true, 'the event loop must stay responsive during preflight');
|
||||
});
|
||||
|
||||
test('reports a child-process failure without leaking internals or throwing', async () => {
|
||||
const error = new Error('spawn failed');
|
||||
error.stderr = 'live-wrap.mjs: element not found\n';
|
||||
const result = await runGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-fail',
|
||||
count: 1,
|
||||
element: { classes: ['hero'] },
|
||||
}, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
execFileImpl: () => Promise.reject(error),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'live-wrap.mjs: element not found');
|
||||
assert.ok(typeof result.durationMs === 'number');
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
|
||||
import {
|
||||
prepareGenerationArtifact,
|
||||
publishGenerationArtifact,
|
||||
sha256,
|
||||
} from '../skill/scripts/live/generation-publisher.mjs';
|
||||
|
||||
describe('transactional generation publisher', () => {
|
||||
let tmp;
|
||||
let source;
|
||||
let artifact;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-publisher-'));
|
||||
source = join(tmp, 'page.html');
|
||||
artifact = join(tmp, 'variant.html');
|
||||
writeFileSync(source, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div></div></main>');
|
||||
store = createLiveSessionStore({ cwd: tmp, sessionId: 'abc12345' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'abc12345',
|
||||
generationEpoch: 1,
|
||||
action: 'polish',
|
||||
count: 3,
|
||||
element: { outerHTML: '<main>Original</main>' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it('atomically publishes an artifact that matches the fenced source revision', () => {
|
||||
const before = readFileSync(source, 'utf-8');
|
||||
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1">Variant</div></div></main>');
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345',
|
||||
epoch: 1,
|
||||
sourceFile: source,
|
||||
artifactFile: artifact,
|
||||
expectedSourceHash: sha256(before),
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.equal(result.arrivedVariants, 1);
|
||||
assert.equal(readFileSync(source, 'utf-8'), readFileSync(artifact, 'utf-8'));
|
||||
const snapshot = store.getSnapshot('abc12345');
|
||||
assert.equal(snapshot.phase, 'variants_progress');
|
||||
assert.equal(snapshot.publishedRevision, 1);
|
||||
assert.equal(snapshot.deliveredVariants['1'].digest, result.digest);
|
||||
});
|
||||
|
||||
it('prepares a revision artifact with the current epoch and source fence', () => {
|
||||
const result = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.epoch, 1);
|
||||
assert.equal(result.revision, 1);
|
||||
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
|
||||
assert.equal(readFileSync(join(tmp, result.artifactFile), 'utf-8'), readFileSync(source, 'utf-8'));
|
||||
});
|
||||
|
||||
it('rejects a late publication after early accept without touching source', () => {
|
||||
const before = readFileSync(source, 'utf-8');
|
||||
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Late</div></div></main>');
|
||||
store.appendEvent({ type: 'accept', id: 'abc12345', variantId: '1' });
|
||||
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345',
|
||||
epoch: 1,
|
||||
sourceFile: source,
|
||||
artifactFile: artifact,
|
||||
expectedSourceHash: sha256(before),
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
error: 'stale_generation_epoch',
|
||||
canceled: true,
|
||||
phase: 'accept_requested',
|
||||
});
|
||||
assert.equal(readFileSync(source, 'utf-8'), before);
|
||||
});
|
||||
|
||||
it('rejects a stale artifact when source changed after the worker snapshot', () => {
|
||||
const before = readFileSync(source, 'utf-8');
|
||||
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Variant</div></div></main>');
|
||||
writeFileSync(source, before.replace('Original', 'Changed'));
|
||||
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345',
|
||||
epoch: 1,
|
||||
sourceFile: source,
|
||||
artifactFile: artifact,
|
||||
expectedSourceHash: sha256(before),
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'source_hash_mismatch');
|
||||
assert.match(readFileSync(source, 'utf-8'), /Changed/);
|
||||
});
|
||||
|
||||
it('keeps an already reviewable source variant immutable across revisions', () => {
|
||||
const firstSource = '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><section><div>First</div></section></div></div></main>';
|
||||
writeFileSync(artifact, firstSource);
|
||||
const first = publishGenerationArtifact({
|
||||
id: 'abc12345',
|
||||
epoch: 1,
|
||||
sourceFile: source,
|
||||
artifactFile: artifact,
|
||||
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
|
||||
arrivedVariants: 1,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
|
||||
const changed = firstSource.replace('First', 'Silently changed')
|
||||
.replace('</div></div></main>', '</div><div data-impeccable-variant="2">Second</div></div></main>');
|
||||
writeFileSync(join(tmp, prepared.artifactFile), changed);
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345',
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: source,
|
||||
artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: 2,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'published_variant_changed');
|
||||
assert.equal(result.variant, 1);
|
||||
assert.equal(readFileSync(source, 'utf-8'), firstSource);
|
||||
});
|
||||
|
||||
it('allows the deferred parameter manifest without weakening prior markup immutability', () => {
|
||||
const firstSource = '<main><div data-impeccable-variants="abc12345"><style data-impeccable-css="abc12345">@scope ([data-impeccable-variant="1"]) { h1 { color: red; } }</style><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><h1>First</h1></div></div></main>';
|
||||
writeFileSync(artifact, firstSource);
|
||||
const first = publishGenerationArtifact({
|
||||
id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact,
|
||||
expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
|
||||
const withParams = firstSource
|
||||
.replace('<div data-impeccable-variant="1"', '<div data-impeccable-variant="1" data-impeccable-params=\'[{"id":"scale"}]\'')
|
||||
.replace('</div></main>', '<div data-impeccable-variant="2">Second</div></div></main>');
|
||||
writeFileSync(join(tmp, prepared.artifactFile), withParams);
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 2, expectedVariants: 3, cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.match(readFileSync(source, 'utf-8'), /data-impeccable-params/);
|
||||
});
|
||||
|
||||
it('rejects later source revisions that restyle an already reviewable variant', () => {
|
||||
const firstSource = '<main><div data-impeccable-variants="abc12345"><style data-impeccable-css="abc12345">@scope ([data-impeccable-variant="1"]) { :scope > h1 { color: red; } }</style><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><h1>First</h1></div></div></main>';
|
||||
writeFileSync(artifact, firstSource);
|
||||
const first = publishGenerationArtifact({
|
||||
id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact,
|
||||
expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
|
||||
const changed = firstSource.replace('color: red', 'color: blue');
|
||||
writeFileSync(join(tmp, prepared.artifactFile), changed);
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'published_variant_css_changed', JSON.stringify(result));
|
||||
assert.equal(readFileSync(source, 'utf-8'), firstSource);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transactional Svelte component publisher', () => {
|
||||
let tmp;
|
||||
let source;
|
||||
let manifestPath;
|
||||
let componentDir;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-svelte-publisher-'));
|
||||
source = join(tmp, 'src', 'routes', '+page.svelte');
|
||||
componentDir = join(tmp, 'node_modules', '.impeccable-live', 'svelte123');
|
||||
manifestPath = join(componentDir, 'manifest.json');
|
||||
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
|
||||
mkdirSync(componentDir, { recursive: true });
|
||||
writeFileSync(source, '<main><h1>{title}</h1></main>\n');
|
||||
writeFileSync(manifestPath, JSON.stringify({
|
||||
id: 'svelte123',
|
||||
previewMode: 'svelte-component',
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
sourceStartLine: 1,
|
||||
sourceEndLine: 1,
|
||||
count: 3,
|
||||
propContract: [{ prop: 'title', expr: 'title', placeholder: '{title}' }],
|
||||
originalMarkup: '<main><h1>{title}</h1></main>',
|
||||
componentDir: 'node_modules/.impeccable-live/svelte123',
|
||||
runtimeModule: '/node_modules/.impeccable-live/__runtime.js',
|
||||
}, null, 2) + '\n');
|
||||
for (let variant = 1; variant <= 3; variant++) {
|
||||
writeFileSync(join(componentDir, `v${variant}.svelte`), `<main>Stub ${variant}</main>\n`);
|
||||
}
|
||||
store = createLiveSessionStore({ cwd: tmp, sessionId: 'svelte123' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'svelte123',
|
||||
generationEpoch: 1,
|
||||
action: 'polish',
|
||||
count: 3,
|
||||
element: { outerHTML: '<main><h1>Original</h1></main>' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it('prepares an isolated component directory fenced against the real route', () => {
|
||||
const result = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.previewMode, 'svelte-component');
|
||||
assert.equal(result.sourceFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
|
||||
assert.equal(result.targetSourceFile, 'src/routes/+page.svelte');
|
||||
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
|
||||
const artifactManifest = JSON.parse(readFileSync(join(tmp, result.artifactFile), 'utf-8'));
|
||||
assert.equal(artifactManifest.componentDir, result.componentDir);
|
||||
assert.equal(readFileSync(join(tmp, result.componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
|
||||
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.svelte'), '<main>Prepared only</main>\n');
|
||||
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
|
||||
});
|
||||
|
||||
it('publishes components before committing the arrived manifest and journals preview metadata', () => {
|
||||
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
const artifactManifestPath = join(tmp, prepared.artifactFile);
|
||||
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
|
||||
artifactManifest.arrivedVariants = 1;
|
||||
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
|
||||
writeFileSync(join(tmp, prepared.componentDir, 'v1.svelte'), '<main>First live variant</main>\n');
|
||||
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'svelte123',
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: manifestPath,
|
||||
artifactFile: artifactManifestPath,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: 1,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.previewMode, 'svelte-component');
|
||||
assert.equal(result.sourceFile, 'src/routes/+page.svelte');
|
||||
assert.equal(result.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
|
||||
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
|
||||
assert.equal(readFileSync(source, 'utf-8'), '<main><h1>{title}</h1></main>\n');
|
||||
const liveManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
assert.equal(liveManifest.arrivedVariants, 1);
|
||||
assert.equal(liveManifest.componentDir, 'node_modules/.impeccable-live/svelte123');
|
||||
const snapshot = store.getSnapshot('svelte123');
|
||||
assert.equal(snapshot.arrivedVariants, 1);
|
||||
assert.equal(snapshot.previewMode, 'svelte-component');
|
||||
assert.equal(snapshot.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
|
||||
});
|
||||
|
||||
it('keeps published variants immutable across later revisions', () => {
|
||||
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
|
||||
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
const before = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
|
||||
|
||||
const result = publishSveltePrepared(second, {
|
||||
arrived: 2,
|
||||
edits: {
|
||||
1: '<main>Silently changed first variant</main>\n',
|
||||
2: '<main>Second live variant</main>\n',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'published_variant_changed');
|
||||
assert.equal(result.variant, 1);
|
||||
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), before);
|
||||
assert.equal(JSON.parse(readFileSync(manifestPath, 'utf-8')).arrivedVariants, 1);
|
||||
});
|
||||
|
||||
it('publishes later variants and params without rewriting an already reviewable variant', () => {
|
||||
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
|
||||
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
writeFileSync(join(tmp, second.componentDir, 'params.json'), '{"2":[{"id":"density"}]}\n');
|
||||
|
||||
const result = publishSveltePrepared(second, {
|
||||
arrived: 3,
|
||||
edits: {
|
||||
2: '<main>Second live variant</main>\n',
|
||||
3: '<main>Third live variant</main>\n',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.arrivedVariants, 3);
|
||||
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
|
||||
assert.equal(readFileSync(join(componentDir, 'v2.svelte'), 'utf-8'), '<main>Second live variant</main>\n');
|
||||
assert.equal(existsSync(join(componentDir, 'params.json')), true);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(componentDir, 'params.json'), 'utf-8')), {
|
||||
2: [{ id: 'density' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a prepared Svelte publication after Accept without touching live artifacts', () => {
|
||||
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
|
||||
const beforeManifest = readFileSync(manifestPath, 'utf-8');
|
||||
const beforeVariant = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
|
||||
store.appendEvent({ type: 'accept', id: 'svelte123', variantId: '1' });
|
||||
|
||||
const result = publishSveltePrepared(prepared, {
|
||||
arrived: 1,
|
||||
edits: { 1: '<main>Too late</main>\n' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'stale_generation_epoch');
|
||||
assert.equal(readFileSync(manifestPath, 'utf-8'), beforeManifest);
|
||||
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), beforeVariant);
|
||||
});
|
||||
|
||||
it('rejects a live component directory masquerading as a staged artifact', () => {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
manifest.arrivedVariants = 1;
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
const result = publishGenerationArtifact({
|
||||
id: 'svelte123',
|
||||
epoch: 1,
|
||||
sourceFile: manifestPath,
|
||||
artifactFile: manifestPath,
|
||||
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
|
||||
arrivedVariants: 1,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'artifact_not_staged');
|
||||
});
|
||||
|
||||
function publishSveltePrepared(prepared, { arrived, edits }) {
|
||||
const artifactManifestPath = join(tmp, prepared.artifactFile);
|
||||
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
|
||||
artifactManifest.arrivedVariants = arrived;
|
||||
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
|
||||
for (const [variant, content] of Object.entries(edits)) {
|
||||
writeFileSync(join(tmp, prepared.componentDir, `v${variant}.svelte`), content);
|
||||
}
|
||||
return publishGenerationArtifact({
|
||||
id: 'svelte123',
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: manifestPath,
|
||||
artifactFile: artifactManifestPath,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: arrived,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -389,4 +389,71 @@ const title = 'Test';
|
||||
const afterRemove = readFileSync(file, 'utf-8');
|
||||
assert.equal(afterRemove, original, 'CRLF file should round-trip cleanly after remove');
|
||||
});
|
||||
|
||||
it('uses an idempotent dev-only client plugin for a Nuxt 4 app directory', () => {
|
||||
const configSource = `export default defineNuxtConfig({\n devtools: { enabled: false },\n});\n`;
|
||||
const appSource = `<template>\n <NuxtPage />\n</template>\n`;
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), configSource);
|
||||
mkdirSync(join(tmp, 'app'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'app', 'app.vue'), appSource);
|
||||
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['app/app.vue'],
|
||||
insertBefore: '</template>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
const first = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
|
||||
const firstPlugin = readFileSync(pluginPath, 'utf-8');
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.adapter, 'nuxt');
|
||||
assert.equal(first.results[0].file, 'app/plugins/impeccable-live.client.ts');
|
||||
assert.equal(first.results[0].changed, true);
|
||||
assert.match(firstPlugin, /if \(!import\.meta\.dev/);
|
||||
assert.match(firstPlugin, /data-impeccable-live-nuxt/);
|
||||
assert.match(firstPlugin, /localhost:8400\/live\.js/);
|
||||
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource, 'Nuxt config remains user-owned');
|
||||
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource, 'app.vue remains user-owned');
|
||||
|
||||
const second = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.results[0].changed, false, 'same-port reinjection is byte-idempotent');
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), firstPlugin);
|
||||
|
||||
const moved = runInject(tmp, cfgPath, ['--port', '8401']);
|
||||
assert.equal(moved.ok, true);
|
||||
assert.equal(moved.results[0].changed, true);
|
||||
assert.match(readFileSync(pluginPath, 'utf-8'), /localhost:8401\/live\.js/);
|
||||
assert.doesNotMatch(readFileSync(pluginPath, 'utf-8'), /localhost:8400\/live\.js/);
|
||||
|
||||
const removed = runInject(tmp, cfgPath, ['--remove']);
|
||||
assert.equal(removed.ok, true);
|
||||
assert.equal(removed.adapter, 'nuxt');
|
||||
assert.equal(removed.results[0].removed, true);
|
||||
assert.equal(existsSync(pluginPath), false);
|
||||
assert.equal(readFileSync(join(tmp, 'nuxt.config.ts'), 'utf-8'), configSource);
|
||||
assert.equal(readFileSync(join(tmp, 'app', 'app.vue'), 'utf-8'), appSource);
|
||||
});
|
||||
|
||||
it('respects a literal Nuxt srcDir and never overwrites a user plugin', () => {
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), `export default defineNuxtConfig({ srcDir: 'client/' });\n`);
|
||||
mkdirSync(join(tmp, 'client', 'plugins'), { recursive: true });
|
||||
const pluginPath = join(tmp, 'client', 'plugins', 'impeccable-live.client.ts');
|
||||
const userPlugin = `export default defineNuxtPlugin(() => {});\n`;
|
||||
writeFileSync(pluginPath, userPlugin);
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['client/app.vue'],
|
||||
insertBefore: '</template>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
const result = runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.adapter, 'nuxt');
|
||||
assert.equal(result.results[0].error, 'nuxt_plugin_conflict');
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), userPlugin);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Tests for live/poll-lanes.mjs — which pending event a poll gets next.
|
||||
* Run with: node --test tests/live-poll-lanes.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { eventPriority, selectAvailablePendingEvent } from '../skill/scripts/live/poll-lanes.mjs';
|
||||
|
||||
const entry = (type, seq, leaseUntil = 0, id = type + seq) => ({ event: { id, type }, leaseUntil, seq });
|
||||
|
||||
describe('poll lane priority', () => {
|
||||
it('puts terminal user actions ahead of generation', () => {
|
||||
for (const type of ['accept', 'discard', 'exit']) {
|
||||
assert.ok(
|
||||
eventPriority({ type }) < eventPriority({ type: 'generate' }),
|
||||
`${type} must outrank generate`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('ranks unknown event types last rather than first', () => {
|
||||
assert.ok(eventPriority({ type: 'something-new' }) > eventPriority({ type: 'generate' }));
|
||||
assert.ok(eventPriority({}) > eventPriority({ type: 'generate' }));
|
||||
});
|
||||
|
||||
// This is what makes the browser's optimistic Accept safe. The browser returns
|
||||
// to PICKING as soon as /events durably journals the accept, before the source
|
||||
// write happens, so the user can pick and hit Go while the accept is still
|
||||
// queued. If that generate were leased first, its preflight would wrap source
|
||||
// that still contains the previous session's variant markers.
|
||||
it('delivers a queued accept before a generate the user queued afterwards', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('accept', 1),
|
||||
entry('generate', 2),
|
||||
]);
|
||||
assert.equal(selected.event.type, 'accept');
|
||||
});
|
||||
|
||||
it('delivers the accept first even when the generate was queued earlier', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('generate', 1),
|
||||
entry('accept', 2),
|
||||
]);
|
||||
assert.equal(
|
||||
selected.event.type,
|
||||
'accept',
|
||||
'priority must beat arrival order, or a slow poller preflights against stale source',
|
||||
);
|
||||
});
|
||||
|
||||
it('breaks ties within one lane by arrival order', () => {
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('generate', 7),
|
||||
entry('generate', 3),
|
||||
]);
|
||||
assert.equal(selected.seq, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll lane availability', () => {
|
||||
it('skips an entry whose lease is still held', () => {
|
||||
const now = 1_000_000;
|
||||
const selected = selectAvailablePendingEvent([
|
||||
entry('accept', 1, now + 30_000),
|
||||
entry('generate', 2),
|
||||
], { now });
|
||||
assert.equal(selected.event.type, 'generate', 'a leased accept must not be handed out twice');
|
||||
});
|
||||
|
||||
it('re-offers an entry once its lease has expired', () => {
|
||||
const now = 1_000_000;
|
||||
const selected = selectAvailablePendingEvent([entry('accept', 1, now - 1)], { now });
|
||||
assert.equal(selected.event.type, 'accept');
|
||||
});
|
||||
|
||||
it('returns null when everything is leased', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(selectAvailablePendingEvent([entry('accept', 1, now + 5_000)], { now }), null);
|
||||
});
|
||||
|
||||
it('returns null for an empty queue', () => {
|
||||
assert.equal(selectAvailablePendingEvent([]), null);
|
||||
});
|
||||
|
||||
it('restricts delivery to the requested types', () => {
|
||||
const entries = [entry('accept', 1), entry('generate', 2)];
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: ['generate'] }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: new Set(['generate']) }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: ['steer'] }), null);
|
||||
});
|
||||
|
||||
it('ignores an empty or absent type filter instead of starving the queue', () => {
|
||||
const entries = [entry('generate', 1)];
|
||||
assert.equal(selectAvailablePendingEvent(entries, { types: null }).event.type, 'generate');
|
||||
assert.equal(selectAvailablePendingEvent(entries, {}).event.type, 'generate');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildPollReplyPayload,
|
||||
isEventPending,
|
||||
manualApplyPollBanner,
|
||||
normalizePollTypes,
|
||||
parseReplyArgs,
|
||||
requiresAgentReply,
|
||||
} from '../skill/scripts/live-poll.mjs';
|
||||
@@ -25,6 +26,15 @@ describe('live-poll reply payloads', () => {
|
||||
'event=live_poll.reply_data actor=agent operation=completion_ack risk=carbonize_flag_dropped_before_server_journal expected={"carbonize":true} actual=' + JSON.stringify(payload.data),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the leased source event type when concurrent work shares a session id', () => {
|
||||
const payload = buildPollReplyPayload('token-1', {
|
||||
id: 'abc12345',
|
||||
type: 'agent_done',
|
||||
sourceEventType: 'accept',
|
||||
});
|
||||
assert.equal(payload.sourceEventType, 'accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('live-poll accept handling', () => {
|
||||
@@ -134,6 +144,7 @@ describe('live-poll stream helpers', () => {
|
||||
assert.equal(requiresAgentReply({ type: 'generate' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'steer' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'manual_edit_apply' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'carbonize_cleanup' }), true);
|
||||
assert.equal(requiresAgentReply({ type: 'prefetch' }), false);
|
||||
assert.equal(requiresAgentReply({ type: 'accept' }), false);
|
||||
assert.equal(requiresAgentReply({ type: 'timeout' }), false);
|
||||
@@ -149,4 +160,12 @@ describe('live-poll stream helpers', () => {
|
||||
assert.equal(isEventPending(status, 'abc12345'), true);
|
||||
assert.equal(isEventPending(status, '00000000'), false);
|
||||
});
|
||||
|
||||
it('normalizes a non-overlapping foreground control lane', () => {
|
||||
assert.deepEqual(
|
||||
normalizePollTypes('steer,manual_edit_apply,carbonize_cleanup,exit,steer'),
|
||||
['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { compileProviderBlocks } from '../scripts/lib/utils.js';
|
||||
import { PROVIDERS } from '../scripts/lib/transformers/providers.js';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
describe('live reference authoring contract', () => {
|
||||
it('keeps setup guidance focused on inferred target paths', () => {
|
||||
it('keeps setup guidance focused on routing live to its reference', () => {
|
||||
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
assert.match(skillSrc, /infer the concrete path and append `--target <path>` to the same command/);
|
||||
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
|
||||
assert.doesNotMatch(skillSrc, /Use this same scripts directory for all Impeccable helper commands/);
|
||||
assert.doesNotMatch(skillSrc, /walk upward for the nearest project `\.agents`, `\.claude`, or `\.cursor` skill/);
|
||||
assert.doesNotMatch(skillSrc, /## Context diagnostics/);
|
||||
@@ -22,7 +23,7 @@ describe('live reference authoring contract', () => {
|
||||
const skillSrc = readFileSync(join(ROOT, 'skill/SKILL.src.md'), 'utf-8');
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
assert.match(skillSrc, /--target <path>/);
|
||||
assert.match(skillSrc, /If the user invoked a sub-command[\s\S]*?reference\/<command>\.md/);
|
||||
assert.doesNotMatch(skillSrc, /TARGET_SELECTION_REQUIRED/);
|
||||
assert.doesNotMatch(skillSrc, /productStatus/);
|
||||
assert.doesNotMatch(skillSrc, /designStatus/);
|
||||
@@ -40,13 +41,16 @@ describe('live reference authoring contract', () => {
|
||||
const openingContract = liveMd.split('\n').slice(0, 60).join('\n');
|
||||
|
||||
assert.match(liveMd, /1\. `live\.mjs`: boot\./);
|
||||
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. After every event or `--reply`, run `live-poll\.mjs` again immediately\. Never pass a short `--timeout=`\./);
|
||||
assert.match(liveMd, /3\. Poll loop with the default long timeout \(600000 ms\)\. Run `live-poll\.mjs` again immediately.*Codex runs this one-shot poll in the foreground\./);
|
||||
assert.match(openingContract, /## Poll loop/);
|
||||
assert.match(openingContract, /No step skipped, no step reordered\./);
|
||||
assert.doesNotMatch(liveMd, /live-copy-edits\.md/);
|
||||
assert.doesNotMatch(liveMd, /IMPECCABLE_LIVE_COPY_AGENT|mock/);
|
||||
assert.match(liveMd, /"manual_edit_apply" → Handle Manual Edit Apply/);
|
||||
assert.match(liveMd, /## Handle `manual_edit_apply`/);
|
||||
assert.match(openingContract, /Codex.*one-shot poll in a \*\*yielded foreground exec session\*\*/);
|
||||
assert.doesNotMatch(openingContract, /dedicated app-server generation lane by default/);
|
||||
assert.doesNotMatch(liveMd, /app-server|IMPECCABLE_LIVE_CODEX_WORKER|codexWorker/);
|
||||
assert.ok(
|
||||
liveMd.indexOf('## Handle `manual_edit_apply`') > liveMd.indexOf('## Handle `prefetch`'),
|
||||
'manual_edit_apply handler section must sit after prefetch in the dispatch order',
|
||||
@@ -60,6 +64,25 @@ describe('live reference authoring contract', () => {
|
||||
assert.match(liveMd, /delegate source edits to `impeccable_manual_edit_applier`/);
|
||||
assert.match(liveMd, /The subagent must not poll or reply/);
|
||||
assert.match(liveMd, /parent live thread keeps the foreground poll loop/);
|
||||
// Generation stays in the main thread on every harness. The generator subagent
|
||||
// was removed after the first real Claude Code run: the parent has to
|
||||
// hand-compress the design system into the handoff, and compression is lossy.
|
||||
// It shipped 0 `var(--token)` uses and 22 raw oklch literals, violating its own
|
||||
// "never invent raw colors" rule, then needed hundreds of lines of hand
|
||||
// carbonize to repair. The parent's context is the job, not overhead.
|
||||
assert.doesNotMatch(
|
||||
liveMd,
|
||||
/impeccable[-_]live[-_]generator/,
|
||||
'live generation must not be delegated to a subagent',
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(ROOT, 'skill/agents/impeccable-live-generator.md')),
|
||||
false,
|
||||
'the live generator agent must not come back without the context problem being solved',
|
||||
);
|
||||
// Copy edits keep their subagent: applying a known set of ops to a named file
|
||||
// is self-contained work, so an isolated context costs nothing.
|
||||
assert.match(manualAgentMd, /codex-name: impeccable_manual_edit_applier/);
|
||||
assert.match(liveMd, /live-accept\.mjs --page-url PAGE_URL/);
|
||||
assert.match(liveMd, /If `repair` is present/);
|
||||
assert.match(liveMd, /Fix the current source/);
|
||||
@@ -106,8 +129,11 @@ describe('live reference authoring contract', () => {
|
||||
|
||||
it('keeps Codex sandbox guidance Codex-only', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
const codexLiveMd = compileProviderBlocks(liveMd, ['codex']);
|
||||
const claudeLiveMd = compileProviderBlocks(liveMd, ['claude-code', 'claude']);
|
||||
// Compile with each provider's real tags rather than hand-written ones, so a
|
||||
// providers.js misconfiguration fails here instead of shipping.
|
||||
const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags);
|
||||
const codexLiveMd = compileFor('codex');
|
||||
const claudeLiveMd = compileFor('claude-code');
|
||||
|
||||
assert.match(
|
||||
codexLiveMd,
|
||||
@@ -121,7 +147,7 @@ describe('live reference authoring contract', () => {
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
codexLiveMd,
|
||||
/<\/?codex>/,
|
||||
/<\/?(codex|live-progressive)>/,
|
||||
'provider block tags should not leak into compiled Codex live reference',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
@@ -131,6 +157,57 @@ describe('live reference authoring contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gives progressive delivery to the harnesses that opt in, and only those', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags);
|
||||
|
||||
// Codex delegates to unblock a foreground poll; Claude Code polls in a
|
||||
// background task. Both can publish variant 1 before the trio is finished.
|
||||
for (const provider of ['codex', 'agents', 'claude-code']) {
|
||||
const compiled = compileFor(provider);
|
||||
assert.match(
|
||||
compiled,
|
||||
/Transactional progressive delivery/,
|
||||
`${provider} should get the progressive publish recipe`,
|
||||
);
|
||||
assert.match(
|
||||
compiled,
|
||||
/Progressive delivery \(Codex, Claude Code\)/,
|
||||
`${provider} should get the progressive delivery policy`,
|
||||
);
|
||||
}
|
||||
|
||||
// Everyone else keeps the atomic single-edit path until their poll loop is
|
||||
// known not to stall on the extra publish calls.
|
||||
for (const provider of ['cursor', 'gemini']) {
|
||||
const compiled = compileFor(provider);
|
||||
assert.doesNotMatch(
|
||||
compiled,
|
||||
/Transactional progressive delivery|Progressive delivery \(Codex, Claude Code\)/,
|
||||
`${provider} has not opted into progressive delivery`,
|
||||
);
|
||||
assert.match(compiled, /\*\*Atomic default:\*\*/, `${provider} should keep the atomic path`);
|
||||
assert.doesNotMatch(
|
||||
compiled,
|
||||
/<\/?live-progressive>/,
|
||||
`capability block tags should not leak into the compiled ${provider} reference`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes every live-publish command through the per-provider scripts path', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
// The progressive recipe used to hardcode `.agents/skills/...`, which is only
|
||||
// correct for the Codex repo-skills bundle. Every other harness would have
|
||||
// been told to run the publisher from a directory its install never creates.
|
||||
assert.doesNotMatch(
|
||||
liveMd,
|
||||
/node\s+\.[a-z-]+\/skills\/impeccable\/scripts\//,
|
||||
'live.md must not hardcode a harness config dir; use {{scripts_path}}',
|
||||
);
|
||||
assert.match(liveMd, /node \{\{scripts_path\}\}\/live-publish\.mjs --prepare/);
|
||||
});
|
||||
|
||||
it('keeps live preview CSS guidance capability-mode driven', () => {
|
||||
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
|
||||
|
||||
|
||||
+466
-1
@@ -111,6 +111,31 @@ it('gitignores local Impeccable runtime artifacts', () => {
|
||||
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
|
||||
});
|
||||
|
||||
it('Stop Live removes Nuxt Vue preview modules and their generated root', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-live-nuxt-stop-'));
|
||||
const generatedRoot = join(cwd, 'app/.impeccable-live');
|
||||
mkdirSync(join(generatedRoot, 'session123'), { recursive: true });
|
||||
writeFileSync(join(cwd, 'nuxt.config.ts'), 'export default defineNuxtConfig({});\n');
|
||||
writeFileSync(join(generatedRoot, '__runtime.js'), 'export const runtime = true;\n');
|
||||
writeFileSync(join(generatedRoot, 'session123', 'v1.vue'), '<template><h1>Preview</h1></template>\n');
|
||||
|
||||
let live;
|
||||
try {
|
||||
live = await startServer(8498, { cwd });
|
||||
const exited = new Promise((resolve) => live.proc.once('exit', resolve));
|
||||
await stopServer(live.port, live.token);
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('live server did not stop')), 2_000)),
|
||||
]);
|
||||
assert.equal(existsSync(join(generatedRoot, '__runtime.js')), false);
|
||||
assert.equal(existsSync(generatedRoot), false);
|
||||
} finally {
|
||||
live?.proc?.kill();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function readSseUntil(reader, decoder, needle, maxReads = 12) {
|
||||
let text = '';
|
||||
for (let i = 0; i < maxReads; i++) {
|
||||
@@ -224,6 +249,40 @@ describe('live-server integration', () => {
|
||||
assert.equal(data.agentPolling, false);
|
||||
});
|
||||
|
||||
it('/status stops reporting agentPolling as soon as a poll returns an event', async () => {
|
||||
await drainPolls(server);
|
||||
const pollPromise = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000&leaseMs=30000`,
|
||||
).then((response) => response.json());
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const eventRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id: 'aabbcc77',
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Truthful poll</button>', tagName: 'BUTTON' },
|
||||
}),
|
||||
});
|
||||
assert.equal(eventRes.status, 200);
|
||||
const event = await pollPromise;
|
||||
assert.equal(event.id, 'aabbcc77');
|
||||
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.agentPolling, false);
|
||||
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id: event.id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('/live.js serves script with token injected', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/live.js`);
|
||||
assert.equal(res.status, 200);
|
||||
@@ -2023,6 +2082,59 @@ colors: {}
|
||||
assert.equal(data.type, 'timeout');
|
||||
});
|
||||
|
||||
it('/poll type filters keep parallel poll consumers disjoint', async () => {
|
||||
await drainPolls(server);
|
||||
const controlPoll = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=steer,manual_edit_apply,carbonize_cleanup,exit`,
|
||||
).then((response) => response.json());
|
||||
const workerPoll = fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000&types=generate,accept,discard,prefetch`,
|
||||
).then((response) => response.json());
|
||||
|
||||
const steer = {
|
||||
token: server.token,
|
||||
type: 'steer',
|
||||
id: 'aabbcc01',
|
||||
pageUrl: '/',
|
||||
message: 'Keep this on the foreground lane',
|
||||
};
|
||||
const generate = {
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id: 'aabbcc02',
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button id="lane-test">Book</button>', id: 'lane-test', tagName: 'BUTTON' },
|
||||
};
|
||||
for (const event of [steer, generate]) {
|
||||
const response = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
|
||||
const [controlEvent, workerEvent] = await Promise.all([controlPoll, workerPoll]);
|
||||
assert.equal(controlEvent.type, 'steer');
|
||||
assert.equal(controlEvent.id, steer.id);
|
||||
assert.equal(workerEvent.type, 'generate');
|
||||
assert.equal(workerEvent.id, generate.id);
|
||||
|
||||
for (const reply of [
|
||||
{ id: steer.id, type: 'steer_done', message: 'Control lane handled it', sourceEventType: 'steer' },
|
||||
{ id: generate.id, type: 'done', sourceEventType: 'generate' },
|
||||
]) {
|
||||
const response = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, ...reply }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
it('/poll rejects invalid token', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/poll?token=wrong&timeout=100`);
|
||||
assert.equal(res.status, 401);
|
||||
@@ -2142,6 +2254,9 @@ colors: {}
|
||||
assert.equal(event.id, 'a1b2c3d4');
|
||||
assert.equal(event.action, 'bolder');
|
||||
assert.equal(event.count, 2);
|
||||
assert.equal(event.scaffoldAttempted, true);
|
||||
assert.equal(event.scaffoldError, 'insufficient_locator');
|
||||
assert.equal(Number.isFinite(event.generationReadyAt), true);
|
||||
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
@@ -2187,6 +2302,42 @@ colors: {}
|
||||
|
||||
it('accepts checkpoint events without exposing them as agent poll work', async () => {
|
||||
await drainPolls(server);
|
||||
const partialRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
reason: 'browser_resumed',
|
||||
revision: 1,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
}),
|
||||
});
|
||||
assert.equal(partialRes.status, 200);
|
||||
|
||||
const secondRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 2,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 2,
|
||||
visibleVariant: 2,
|
||||
}),
|
||||
});
|
||||
assert.equal(secondRes.status, 200);
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2195,8 +2346,10 @@ colors: {}
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3d7',
|
||||
phase: 'cycling',
|
||||
revision: 2,
|
||||
reason: 'variants_ready',
|
||||
revision: 3,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 2,
|
||||
paramValues: { density: 'packed' },
|
||||
@@ -2214,6 +2367,148 @@ colors: {}
|
||||
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d7.snapshot.json'), 'utf-8'));
|
||||
assert.equal(snapshot.visibleVariant, 2);
|
||||
assert.deepEqual(snapshot.paramValues, { density: 'packed' });
|
||||
assert.ok(snapshot.generationTimings.first_reviewable?.at);
|
||||
assert.ok(snapshot.generationTimings.second_reviewable?.at);
|
||||
assert.ok(snapshot.generationTimings.all_variants_ready?.at);
|
||||
assert.ok(snapshot.generationTimings.first_reviewable.at <= snapshot.generationTimings.second_reviewable.at);
|
||||
assert.ok(snapshot.generationTimings.second_reviewable.at <= snapshot.generationTimings.all_variants_ready.at);
|
||||
|
||||
const atomicRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3da',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_ready',
|
||||
revision: 1,
|
||||
owner: 'browser-a',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 1,
|
||||
}),
|
||||
});
|
||||
assert.equal(atomicRes.status, 200);
|
||||
const atomicSnapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3da.snapshot.json'), 'utf-8'));
|
||||
assert.ok(atomicSnapshot.generationTimings.first_reviewable?.at);
|
||||
assert.equal(
|
||||
atomicSnapshot.generationTimings.first_reviewable.at,
|
||||
atomicSnapshot.generationTimings.all_variants_ready?.at,
|
||||
'atomic delivery makes the first variant and full set reviewable together',
|
||||
);
|
||||
});
|
||||
|
||||
it('journals and streams agent progress without leasing it as work', async () => {
|
||||
await drainPolls(server);
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
await reader.read();
|
||||
const progress = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'agent_phase',
|
||||
id: 'a1b2c3e1',
|
||||
phase: 'first_variant_generating',
|
||||
owner: 'live-agent',
|
||||
}),
|
||||
});
|
||||
assert.equal(progress.status, 200);
|
||||
const message = new TextDecoder().decode((await reader.read()).value);
|
||||
controller.abort();
|
||||
assert.match(message, /"type":"agent_phase"/);
|
||||
assert.match(message, /"phase":"first_variant_generating"/);
|
||||
const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
|
||||
assert.equal(polled.type, 'timeout');
|
||||
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3e1.snapshot.json'), 'utf-8'));
|
||||
assert.ok(snapshot.generationTimings.first_variant_generating?.at);
|
||||
});
|
||||
|
||||
it('streams Svelte component checkpoints as progressive preview updates', async () => {
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await reader.read(); // connected
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3de',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 1,
|
||||
owner: 'svelte-worker',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
previewMode: 'svelte-component',
|
||||
previewFile: 'node_modules/.impeccable-live/a1b2c3de/manifest.json',
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const { value } = await reader.read();
|
||||
const message = decoder.decode(value);
|
||||
assert.match(message, /"type":"variant_progress"/);
|
||||
assert.match(message, /"arrivedVariants":1/);
|
||||
assert.match(message, /"previewMode":"svelte-component"/);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it('streams source checkpoints so no-HMR frameworks can review variant 1', async () => {
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reader = sseRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await reader.read(); // connected
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'checkpoint',
|
||||
id: 'a1b2c3df',
|
||||
phase: 'cycling',
|
||||
reason: 'variants_progress',
|
||||
revision: 1,
|
||||
owner: 'source-worker',
|
||||
expectedVariants: 3,
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
previewMode: 'source',
|
||||
previewFile: 'app/pages/index.vue',
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
publicationKind: 'params',
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const { value } = await reader.read();
|
||||
const message = decoder.decode(value);
|
||||
assert.match(message, /"type":"variant_progress"/);
|
||||
assert.match(message, /"arrivedVariants":1/);
|
||||
assert.match(message, /"previewMode":"source"/);
|
||||
assert.match(message, /"previewFile":"app\/pages\/index.vue"/);
|
||||
assert.match(message, /"publicationKind":"params"/);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it('redelivers an unacknowledged browser event after helper server restart', async () => {
|
||||
@@ -2360,6 +2655,105 @@ colors: {}
|
||||
assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue');
|
||||
});
|
||||
|
||||
it('retires the leased Generate when early Accept or Discard takes ownership', async () => {
|
||||
await drainPolls(server);
|
||||
for (const [type, id] of [['accept', 'ea11ac01'], ['discard', 'ea11dc01']]) {
|
||||
const generated = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'bolder',
|
||||
count: 3,
|
||||
element: { outerHTML: '<section>early choice</section>', tagName: 'section' },
|
||||
}),
|
||||
});
|
||||
assert.equal(generated.status, 200);
|
||||
const generation = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=40`).then((response) => response.json());
|
||||
assert.equal(generation.id, id);
|
||||
|
||||
const chosen = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type,
|
||||
id,
|
||||
...(type === 'accept' ? { variantId: '1' } : {}),
|
||||
}),
|
||||
});
|
||||
assert.equal(chosen.status, 200);
|
||||
const choice = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=${type}&timeout=100&leaseMs=40`).then((response) => response.json());
|
||||
assert.equal(choice.type, type);
|
||||
assert.equal(choice.id, id);
|
||||
const reply = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
id,
|
||||
sourceEventType: type,
|
||||
type: type === 'discard' ? 'discarded' : 'complete',
|
||||
}),
|
||||
});
|
||||
assert.equal(reply.status, 200);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=30&leaseMs=20`).then((response) => response.json());
|
||||
assert.equal(stale.type, 'timeout', `${type} must prevent Generate redelivery after its old lease expires`);
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('releases a failed worker Generate lease without consuming or broadcasting it', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'fa11bac1';
|
||||
const generated = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'bolder',
|
||||
count: 3,
|
||||
element: { outerHTML: '<article>fallback</article>', tagName: 'article' },
|
||||
}),
|
||||
});
|
||||
assert.equal(generated.status, 200);
|
||||
const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=5000`).then((response) => response.json());
|
||||
assert.equal(leased.id, id);
|
||||
|
||||
const retried = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
id,
|
||||
type: 'retry',
|
||||
sourceEventType: 'generate',
|
||||
}),
|
||||
});
|
||||
assert.equal(retried.status, 200);
|
||||
assert.equal((await retried.json()).released, true);
|
||||
|
||||
const fallback = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=100`).then((response) => response.json());
|
||||
assert.equal(fallback.id, id);
|
||||
assert.equal(fallback.type, 'generate');
|
||||
const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json());
|
||||
assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), true);
|
||||
|
||||
const done = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
assert.equal(done.status, 200);
|
||||
});
|
||||
|
||||
it('wakes a parked poll as soon as a missed-ack lease expires', async () => {
|
||||
await drainPolls(server);
|
||||
|
||||
@@ -2690,4 +3084,75 @@ colors: {}
|
||||
const data = await postRes.json();
|
||||
assert.match(data.error, /freeformPrompt or annotations/i);
|
||||
});
|
||||
|
||||
// A stale generate worker's `error` reply used to acknowledge *any* pending
|
||||
// event for its id, because inferSourceEventType returned undefined and
|
||||
// acknowledgePendingEvent treats that as a wildcard. It ate the user's queued
|
||||
// Accept, which was then never handed to an agent: the browser sat in SAVING
|
||||
// forever and a restart could not requeue it.
|
||||
it('a stale generate error reply does not consume a queued accept', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'ee55ff66';
|
||||
|
||||
await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Book</button>' },
|
||||
}),
|
||||
});
|
||||
|
||||
// Agent leases the generate.
|
||||
const leased = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=200&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(leased.id, id);
|
||||
assert.equal(leased.type, 'generate');
|
||||
|
||||
// User accepts. This retires the pending generate and queues the accept.
|
||||
await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, type: 'accept', id, variantId: '1' }),
|
||||
});
|
||||
|
||||
// The stale generate worker now fails, using live.md's documented reply.
|
||||
const errRes = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'error', message: 'late failure' }),
|
||||
});
|
||||
assert.equal(errRes.status, 200);
|
||||
|
||||
const status = await (await fetch(
|
||||
`http://localhost:${server.port}/status?token=${server.token}`,
|
||||
)).json();
|
||||
assert.equal(
|
||||
status.pendingEvents.some((e) => e.id === id && e.type === 'accept'),
|
||||
true,
|
||||
'the queued accept must survive a stale generate error',
|
||||
);
|
||||
|
||||
// And it must still be deliverable to the next agent that polls.
|
||||
const next = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=30000`,
|
||||
)).json();
|
||||
assert.equal(next.id, id);
|
||||
assert.equal(next.type, 'accept', 'the accept must reach an agent');
|
||||
|
||||
// Acknowledge the accept explicitly. drainPolls replies `done`, which maps
|
||||
// to `generate`, so it can never retire an accept and would re-lease it in
|
||||
// a loop forever.
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'complete', sourceEventType: 'accept' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,84 @@ describe('live-session-store', () => {
|
||||
assert.equal(active[0].id, 'session-a');
|
||||
});
|
||||
|
||||
it('persists the progressive variant plan across worker restarts', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
const plan = {
|
||||
identityLock: ['Preserve copy'],
|
||||
directions: [
|
||||
{ variantId: 1, name: 'Hierarchy', axis: 'scale', intent: 'Increase hierarchy' },
|
||||
{ variantId: 2, name: 'Composition', axis: 'layout', intent: 'Recompose the root' },
|
||||
{ variantId: 3, name: 'Rhythm', axis: 'spacing', intent: 'Increase rhythm' },
|
||||
],
|
||||
};
|
||||
store.appendEvent({ type: 'generate', id: 'planned-session', count: 3 });
|
||||
store.appendEvent({ type: 'variant_plan', id: 'planned-session', plan });
|
||||
store.appendEvent({ type: 'checkpoint', id: 'planned-session', revision: 1, arrivedVariants: 1 });
|
||||
|
||||
const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan);
|
||||
});
|
||||
|
||||
it('tracks parameter publication separately from variant arrival', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'parameter-phase' });
|
||||
store.appendEvent({ type: 'generate', id: 'parameter-phase', count: 3, generationEpoch: 1 });
|
||||
store.appendEvent({
|
||||
type: 'variant_published', id: 'parameter-phase', revision: 1,
|
||||
generationEpoch: 1, arrivedVariants: 3, publicationKind: 'variants',
|
||||
});
|
||||
assert.equal(store.getSnapshot('parameter-phase').paramsPublished, false);
|
||||
store.appendEvent({
|
||||
type: 'variant_published', id: 'parameter-phase', revision: 2,
|
||||
generationEpoch: 1, arrivedVariants: 3, publicationKind: 'params',
|
||||
});
|
||||
assert.equal(store.getSnapshot('parameter-phase').paramsPublished, true);
|
||||
});
|
||||
|
||||
it('tombstones generation on early accept and ignores late generation writes', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'early-accept',
|
||||
action: 'polish',
|
||||
count: 3,
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint',
|
||||
id: 'early-accept',
|
||||
revision: 1,
|
||||
phase: 'cycling',
|
||||
arrivedVariants: 1,
|
||||
visibleVariant: 1,
|
||||
});
|
||||
store.appendEvent({ type: 'accept', id: 'early-accept', variantId: '1' });
|
||||
store.appendEvent({
|
||||
type: 'checkpoint',
|
||||
id: 'early-accept',
|
||||
revision: 2,
|
||||
phase: 'variants_ready',
|
||||
arrivedVariants: 3,
|
||||
visibleVariant: 3,
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'agent_done',
|
||||
id: 'early-accept',
|
||||
file: 'src/App.jsx',
|
||||
arrivedVariants: 3,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('early-accept');
|
||||
assert.equal(snapshot.phase, 'accept_requested');
|
||||
assert.equal(snapshot.generationCanceled, true);
|
||||
assert.equal(snapshot.cancelReason, 'accept');
|
||||
assert.equal(snapshot.arrivedVariants, 1);
|
||||
assert.equal(snapshot.visibleVariant, 1);
|
||||
assert.equal(
|
||||
snapshot.diagnostics.some((entry) => entry.error === 'late_generation_event_ignored'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports corrupted journal lines while preserving valid prior events', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-session' });
|
||||
store.appendEvent({
|
||||
@@ -161,6 +239,30 @@ describe('live-session-store', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks publication and browser checkpoint revisions independently', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'split-revisions' });
|
||||
store.appendEvent({
|
||||
type: 'generate', id: 'split-revisions', count: 3,
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'section' },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint', id: 'split-revisions', revision: 8, revisionDomain: 'browser',
|
||||
owner: 'browser-a', phase: 'cycling', visibleVariant: 2,
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'checkpoint', id: 'split-revisions', revision: 3, revisionDomain: 'publication',
|
||||
reason: 'variants_progress', phase: 'cycling', arrivedVariants: 3,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('split-revisions');
|
||||
assert.equal(snapshot.browserCheckpointRevision, 8);
|
||||
assert.equal(snapshot.checkpointRevision, 8);
|
||||
assert.equal(snapshot.publicationCheckpointRevision, 3);
|
||||
assert.equal(snapshot.visibleVariant, 2);
|
||||
assert.equal(snapshot.arrivedVariants, 3);
|
||||
assert.equal(snapshot.diagnostics.some((entry) => entry.error === 'stale_checkpoint_ignored'), false);
|
||||
});
|
||||
|
||||
it('keeps carbonize-required accepted sessions active until explicit completion', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'carbonize-session' });
|
||||
store.appendEvent({
|
||||
@@ -284,4 +386,26 @@ describe('live-session-store', () => {
|
||||
assert.equal(migratedSnapshot.expectedVariants, 2);
|
||||
assert.equal(migratedSnapshot.sourceFile, 'src/App.jsx');
|
||||
});
|
||||
|
||||
it('records generation phase timings without replacing the workflow phase', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'phase-session' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'phase-session',
|
||||
count: 3,
|
||||
element: { classes: ['hero'] },
|
||||
});
|
||||
store.appendEvent({
|
||||
type: 'agent_phase',
|
||||
id: 'phase-session',
|
||||
phase: 'source_ready',
|
||||
at: 1234,
|
||||
durationMs: 42,
|
||||
});
|
||||
|
||||
const snapshot = store.getSnapshot('phase-session');
|
||||
assert.equal(snapshot.phase, 'generate_requested');
|
||||
assert.equal(snapshot.generationPhase, 'source_ready');
|
||||
assert.deepEqual(snapshot.generationTimings.source_ready, { at: 1234, durationMs: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Tests for live/source-lock.mjs — the per-source-file mutex guarding the
|
||||
* accept/publish critical sections.
|
||||
* Run with: node --test tests/live-source-lock.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { sourceLockPath, withSourceLockSync } from '../skill/scripts/live/source-lock.mjs';
|
||||
|
||||
const TARGET = 'src/page.html';
|
||||
|
||||
describe('live source-lock', () => {
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-source-lock-'));
|
||||
mkdirSync(join(tmp, 'src'), { recursive: true });
|
||||
writeFileSync(join(tmp, TARGET), '<div>original</div>\n');
|
||||
});
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const writeLock = (body) => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
writeFileSync(lockPath, JSON.stringify(body) + '\n');
|
||||
return lockPath;
|
||||
};
|
||||
|
||||
it('runs the critical section and releases the lock', () => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
const result = withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
assert.equal(existsSync(lockPath), true, 'lock must exist while held');
|
||||
return 'done';
|
||||
}, { cwd: tmp });
|
||||
assert.equal(result, 'done');
|
||||
assert.equal(existsSync(lockPath), false, 'lock must be released');
|
||||
});
|
||||
|
||||
it('throws SOURCE_LOCKED when a live owner holds the lock', () => {
|
||||
// process.pid is this very process, so the recorded owner is alive.
|
||||
writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: Date.now() });
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not sweep a live owner’s lock no matter how old it is', () => {
|
||||
// Age alone must not make a lock stale: a holder suspended mid-write would
|
||||
// otherwise have a second writer admitted to the same source file.
|
||||
const lockPath = writeLock({ owner: 'publish:x', token: 'other', pid: process.pid, at: 0 });
|
||||
const ancient = new Date(Date.now() - 10 * 60_000);
|
||||
utimesSync(lockPath, ancient, ancient);
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'should not run', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
'an old but live lock was stolen',
|
||||
);
|
||||
});
|
||||
|
||||
it('reclaims a lock whose owner process is gone, without waiting out a timeout', () => {
|
||||
// PID 2^22 is above the platform maximum, so it can never be running.
|
||||
writeLock({ owner: 'publish:crashed', token: 'other', pid: 4194304, at: Date.now() });
|
||||
const result = withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp });
|
||||
assert.equal(result, 'acquired', 'a crashed holder must not block the next writer');
|
||||
});
|
||||
|
||||
it('leaves a replacement lock alone when its own was swept', () => {
|
||||
// Simulates: our lock got reclaimed and another writer now owns the file.
|
||||
// Releasing must not unlink the replacement and admit a third writer.
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
writeFileSync(lockPath, JSON.stringify({
|
||||
owner: 'publish:other', token: 'a-different-token', pid: process.pid, at: Date.now(),
|
||||
}) + '\n');
|
||||
}, { cwd: tmp });
|
||||
assert.equal(existsSync(lockPath), true, 'another owner’s lock must survive our release');
|
||||
assert.match(readFileSync(lockPath, 'utf-8'), /a-different-token/);
|
||||
});
|
||||
|
||||
it('retires an unreadable lock only once it is older than the fallback window', () => {
|
||||
const lockPath = writeLock('');
|
||||
assert.throws(
|
||||
() => withSourceLockSync(TARGET, 'accept:a', () => 'x', { cwd: tmp }),
|
||||
(err) => err.code === 'SOURCE_LOCKED',
|
||||
'a fresh unreadable lock is an in-flight acquisition, not garbage',
|
||||
);
|
||||
const ancient = new Date(Date.now() - 120_000);
|
||||
utimesSync(lockPath, ancient, ancient);
|
||||
assert.equal(
|
||||
withSourceLockSync(TARGET, 'accept:a', () => 'acquired', { cwd: tmp }),
|
||||
'acquired',
|
||||
'a stale unreadable lock must be retired',
|
||||
);
|
||||
});
|
||||
|
||||
it('releases the lock even when the critical section throws', () => {
|
||||
const lockPath = sourceLockPath(TARGET, tmp);
|
||||
assert.throws(() => withSourceLockSync(TARGET, 'accept:a', () => {
|
||||
throw new Error('boom');
|
||||
}, { cwd: tmp }), /boom/);
|
||||
assert.equal(existsSync(lockPath), false, 'a thrown critical section must not leak the lock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
|
||||
import {
|
||||
prepareGenerationArtifact,
|
||||
publishGenerationArtifact,
|
||||
} from '../skill/scripts/live/generation-publisher.mjs';
|
||||
import {
|
||||
inlineVueComponentAccept,
|
||||
nuxtViteFsModulePath,
|
||||
removeAllVueComponentSessions,
|
||||
scaffoldVueComponentSession,
|
||||
} from '../skill/scripts/live/vue-component.mjs';
|
||||
|
||||
describe('Nuxt Vue component preview', () => {
|
||||
let tmp;
|
||||
let source;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'impeccable-vue-component-'));
|
||||
source = join(tmp, 'app', 'pages', 'index.vue');
|
||||
mkdirSync(join(tmp, 'app', 'pages'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), 'export default defineNuxtConfig({ ssr: false });\n');
|
||||
writeFileSync(source, [
|
||||
'<template>',
|
||||
' <main>',
|
||||
' <h1 class="hero-title">Hello {{ user.name }}</h1>',
|
||||
' </main>',
|
||||
'</template>',
|
||||
'',
|
||||
'<style scoped>',
|
||||
'.hero-title { font-size: 2rem; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it('stages real Vue SFCs without rewriting the active route', () => {
|
||||
const before = readFileSync(source, 'utf-8');
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 3,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.equal(readFileSync(source, 'utf-8'), before);
|
||||
assert.equal(result.manifest.previewMode, 'vue-component');
|
||||
assert.equal(result.manifest.componentExtension, 'vue');
|
||||
assert.match(result.manifestFile, /^app\/\.impeccable-live\/vue12345\/manifest\.json$/);
|
||||
const variant = readFileSync(join(tmp, result.componentDir, 'v1.vue'), 'utf-8');
|
||||
assert.match(variant, /<template>/);
|
||||
assert.match(variant, /Hello \{\{ name \}\}/);
|
||||
assert.equal(existsSync(join(tmp, 'app/.impeccable-live/__runtime.js')), true);
|
||||
assert.equal(
|
||||
result.manifest.runtimeModule,
|
||||
nuxtViteFsModulePath(join(tmp, 'app/.impeccable-live/__runtime.js'), tmp),
|
||||
);
|
||||
assert.equal(
|
||||
result.manifest.componentModuleBase,
|
||||
nuxtViteFsModulePath(join(tmp, result.componentDir), tmp),
|
||||
);
|
||||
assert.match(result.manifest.runtimeModule, /^\/@fs\//);
|
||||
assert.doesNotMatch(result.manifest.runtimeModule, /^\/app\//);
|
||||
assert.match(result.manifest.componentModuleBase, /^\/@fs\//);
|
||||
});
|
||||
|
||||
it('keeps Vite module URLs valid for literal Nuxt srcDir projects', () => {
|
||||
writeFileSync(join(tmp, 'nuxt.config.ts'), "export default defineNuxtConfig({ srcDir: 'client/' });\n");
|
||||
const clientSource = join(tmp, 'client', 'pages', 'index.vue');
|
||||
mkdirSync(join(tmp, 'client', 'pages'), { recursive: true });
|
||||
writeFileSync(clientSource, '<template><h1>Client app</h1></template>\n');
|
||||
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'clientsrc',
|
||||
count: 1,
|
||||
sourceFile: 'client/pages/index.vue',
|
||||
sourceStartLine: 1,
|
||||
sourceEndLine: 1,
|
||||
originalLines: ['<h1>Client app</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
|
||||
assert.match(result.manifestFile, /^client\/\.impeccable-live\/clientsrc\/manifest\.json$/);
|
||||
assert.match(result.manifest.runtimeModule, /^\/@fs\/.*\/client\/\.impeccable-live\/__runtime\.js$/);
|
||||
assert.match(result.manifest.componentModuleBase, /^\/@fs\/.*\/client\/\.impeccable-live\/clientsrc$/);
|
||||
});
|
||||
|
||||
it('accepts one generated SFC into clean Vue source and restores route expressions', () => {
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 3,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<script setup>',
|
||||
"defineProps({ name: { default: '' } });",
|
||||
'</script>',
|
||||
'<template>',
|
||||
' <h1 class="hero-title variant-one">Welcome {{ name }}</h1>',
|
||||
'</template>',
|
||||
'<style scoped>',
|
||||
'.variant-one { letter-spacing: 0.02em; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const accepted = inlineVueComponentAccept(result.manifest, 1, tmp);
|
||||
assert.equal(accepted.handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.match(next, /Welcome \{\{ user\.name \}\}/);
|
||||
assert.match(next, /class="hero-title variant-one"|class="variant-one hero-title"/);
|
||||
assert.match(next, /\.variant-one \{ letter-spacing: 0\.02em; \}/);
|
||||
assert.doesNotMatch(next, /data-impeccable/);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'manifest.json')), false);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true, 'imported SFC remains until Live shutdown');
|
||||
});
|
||||
|
||||
it('preserves original root directives and valueless attrs a variant omits', () => {
|
||||
const originalRoot = ' <button class="cta" @click="submit" :aria-label="label" v-bind:title="tip" disabled v-cloak>Go</button>';
|
||||
writeFileSync(source, [
|
||||
'<template>',
|
||||
' <main>',
|
||||
originalRoot,
|
||||
' </main>',
|
||||
'</template>',
|
||||
'',
|
||||
].join('\n'));
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [originalRoot],
|
||||
cwd: tmp,
|
||||
});
|
||||
// A restyle variant that keeps only class: every behavior attribute must survive Accept.
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<template>',
|
||||
' <button class="cta cta--bold">Go</button>',
|
||||
'</template>',
|
||||
'<style scoped>',
|
||||
'.cta--bold { font-weight: 700; }',
|
||||
'</style>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.match(next, /@click="submit"/, 'v-on shorthand must not degrade to a literal click attribute');
|
||||
assert.doesNotMatch(next, /\sclick="submit"/, 'sigil-stripped event handler leaked into source');
|
||||
assert.match(next, /:aria-label="label"/);
|
||||
assert.match(next, /v-bind:title="tip"/);
|
||||
assert.match(next, /\bdisabled\b/, 'valueless boolean attr dropped');
|
||||
assert.match(next, /\bv-cloak\b/, 'valueless directive dropped');
|
||||
assert.match(next, /class="cta cta--bold"|class="cta--bold cta"/);
|
||||
});
|
||||
|
||||
it('does not duplicate an attribute the variant wrote in the other shorthand form', () => {
|
||||
const originalRoot = ' <button class="cta" :aria-label="label">Go</button>';
|
||||
writeFileSync(source, ['<template>', ' <main>', originalRoot, ' </main>', '</template>', ''].join('\n'));
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [originalRoot],
|
||||
cwd: tmp,
|
||||
});
|
||||
writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
|
||||
'<template>',
|
||||
' <button class="cta" v-bind:aria-label="label">Go</button>',
|
||||
'</template>',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
assert.equal(inlineVueComponentAccept(result.manifest, 1, tmp).handled, true);
|
||||
const next = readFileSync(source, 'utf-8');
|
||||
assert.doesNotMatch(next, /:aria-label="label"[^>]*v-bind:aria-label|v-bind:aria-label="label"[^>]*:aria-label/,
|
||||
'shorthand and longhand of one attr both emitted, which is a Vue compile error');
|
||||
});
|
||||
|
||||
it('removes deferred SFCs, the shared runtime, and the generated root on Live shutdown', () => {
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 1,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
inlineVueComponentAccept(result.manifest, 1, tmp);
|
||||
const root = join(tmp, 'app/.impeccable-live');
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), true);
|
||||
assert.equal(existsSync(join(tmp, result.componentDir, 'v1.vue')), true);
|
||||
|
||||
removeAllVueComponentSessions(tmp);
|
||||
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), false);
|
||||
assert.equal(existsSync(root), false);
|
||||
});
|
||||
|
||||
it('publishes manifest-last, preserves the route, and rejects late work after Accept', () => {
|
||||
const result = scaffoldVueComponentSession({
|
||||
id: 'vue12345',
|
||||
count: 3,
|
||||
sourceFile: 'app/pages/index.vue',
|
||||
sourceStartLine: 3,
|
||||
sourceEndLine: 3,
|
||||
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
|
||||
cwd: tmp,
|
||||
});
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'vue12345' });
|
||||
store.appendEvent({
|
||||
type: 'generate',
|
||||
id: 'vue12345',
|
||||
generationEpoch: 1,
|
||||
count: 3,
|
||||
action: 'polish',
|
||||
element: { outerHTML: '<h1>Hello Paul</h1>' },
|
||||
});
|
||||
const routeBefore = readFileSync(source, 'utf-8');
|
||||
const prepared = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
|
||||
assert.equal(prepared.ok, true);
|
||||
assert.equal(prepared.previewMode, 'vue-component');
|
||||
const artifactManifest = JSON.parse(readFileSync(join(tmp, prepared.artifactFile), 'utf-8'));
|
||||
artifactManifest.arrivedVariants = 1;
|
||||
writeFileSync(join(tmp, prepared.artifactFile), JSON.stringify(artifactManifest, null, 2) + '\n');
|
||||
writeFileSync(join(tmp, prepared.componentDir, 'v1.vue'), '<template><h1>First</h1></template>\n');
|
||||
|
||||
const published = publishGenerationArtifact({
|
||||
id: 'vue12345',
|
||||
epoch: prepared.epoch,
|
||||
sourceFile: result.manifestFile,
|
||||
artifactFile: prepared.artifactFile,
|
||||
expectedSourceHash: prepared.expectedSourceHash,
|
||||
arrivedVariants: 1,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
assert.equal(published.ok, true);
|
||||
assert.equal(published.previewMode, 'vue-component');
|
||||
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
|
||||
assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1);
|
||||
|
||||
const late = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
|
||||
const lateManifest = JSON.parse(readFileSync(join(tmp, late.artifactFile), 'utf-8'));
|
||||
lateManifest.arrivedVariants = 2;
|
||||
writeFileSync(join(tmp, late.artifactFile), JSON.stringify(lateManifest, null, 2) + '\n');
|
||||
writeFileSync(join(tmp, late.componentDir, 'v2.vue'), '<template><h1>Second</h1></template>\n');
|
||||
store.appendEvent({ type: 'accept', id: 'vue12345', variantId: '1' });
|
||||
const rejected = publishGenerationArtifact({
|
||||
id: 'vue12345',
|
||||
epoch: late.epoch,
|
||||
sourceFile: result.manifestFile,
|
||||
artifactFile: late.artifactFile,
|
||||
expectedSourceHash: late.expectedSourceHash,
|
||||
arrivedVariants: 2,
|
||||
expectedVariants: 3,
|
||||
cwd: tmp,
|
||||
});
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error, 'stale_generation_epoch');
|
||||
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
|
||||
assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1,
|
||||
'the manifest must still advertise only the variant published before Accept');
|
||||
// A rejected publish must not have touched the session dir at all: v2 still
|
||||
// holds its untouched scaffold stub rather than the late variant's markup.
|
||||
const v2AfterReject = readFileSync(join(tmp, result.componentDir, 'v2.vue'), 'utf-8');
|
||||
assert.doesNotMatch(v2AfterReject, /Second/,
|
||||
'a rejected late publish must not write variant files into the session dir');
|
||||
assert.match(v2AfterReject, /Variant 2: add scoped CSS here/, 'v2 must still be the scaffold stub');
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,9 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
buildSearchQueries,
|
||||
@@ -253,6 +253,7 @@ describe('wrapCli integration', () => {
|
||||
assert.ok(!modified.includes('data-impeccable-variant="original" style="display: none"'));
|
||||
});
|
||||
|
||||
|
||||
it('wraps a JSX element and uses JSX comment syntax', () => {
|
||||
const jsx = `export default function App() {
|
||||
return (
|
||||
@@ -780,6 +781,34 @@ export default function App() {
|
||||
assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
|
||||
});
|
||||
|
||||
it('refuses multiple dynamic source branches when rendered text cannot identify one', () => {
|
||||
const astro = `---
|
||||
const results = [{ title: 'Result 01' }, { title: 'Result 02' }];
|
||||
---
|
||||
<main>
|
||||
<article class="result-card"><h2>{results[0].title}</h2></article>
|
||||
<article class="result-card"><h2>{results[1].title}</h2></article>
|
||||
</main>`;
|
||||
const file = join(tmp, 'Results.astro');
|
||||
writeFileSync(file, astro);
|
||||
|
||||
let errPayload;
|
||||
try {
|
||||
execSync(
|
||||
`node skill/scripts/live-wrap.mjs --id dyn2 --count 3 --classes "result-card" --tag "article" --text "Result 02 rendered body" --file "${file}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
assert.fail('Should have refused an unsafe first-match fallback');
|
||||
} catch (err) {
|
||||
errPayload = JSON.parse(err.stderr.toString().trim());
|
||||
}
|
||||
|
||||
assert.equal(errPayload.error, 'element_ambiguous');
|
||||
assert.equal(errPayload.reason, 'rendered_text_not_in_source');
|
||||
assert.equal(errPayload.candidates.length, 2);
|
||||
assert.doesNotMatch(readFileSync(file, 'utf-8'), /impeccable-variants-start/);
|
||||
});
|
||||
|
||||
it('errors with element_ambiguous when --text matches multiple identical branches', () => {
|
||||
// Two <aside className="card"> with truly identical body text. --text
|
||||
// can't pick a winner — wrap should refuse rather than silently land.
|
||||
|
||||
Reference in New Issue
Block a user