mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
Live: polling rework, source locks, preflight scaffolding, Vue previews
Carved out of #371, minus progressive publication. Everything here works against real project source the way main's Live already does: the agent writes variants into the file the browser loaded, HMR fires, Accept promotes and carbonizes. Nothing is staged anywhere. Poll lanes. Events now carry an explicit priority: accept/discard/exit ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A long generate can no longer sit in front of the Accept the user just clicked. leaseEvent claims its lease before awaiting, so a slow prepare cannot hand the same event to two pollers. Source locks. A per-file mutex around every accept and discard path, keyed on a digest of the absolute path. Staleness is decided by owner-pid liveness rather than mtime, so a wedged lock clears when its owner dies instead of after an arbitrary timeout, and a slow-but-live accept is never stolen from. Only the owning process can release a lock. Preflight scaffolding. The server runs live-wrap (or live-insert) before the poll returns and hands the result back as event.scaffold. That walk is measured at ~7.6s on a large repo; moving it off the agent's critical path removes a deterministic tool round trip without touching the generated design. Falls back cleanly to the agent running the helper itself. Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching the existing Svelte component path: variants compile as real SFCs from a dev-only directory so the route is never rewritten during generation, and Vite mounts them without invalidating page state. Accept is the only route write. Includes a Vue attr tokenizer that normalizes shorthand bindings (@x, :x, #x) to their canonical forms. Accept hardening. Every thrown failure now returns mode: 'error' rather than an ambiguous unhandled result, so a real failure is never classified as a deliberate manual handoff and silently dropped. The marker search skips node_modules/.git/dist/build/.impeccable. Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs. Assisted-by: Claude Code
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
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 {
|
||||
@@ -18,66 +17,6 @@ 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',
|
||||
// Only the two offsets are required; blur/spread default to 0.
|
||||
'Two Length Edge',
|
||||
'Two Length Trailing Inset 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',
|
||||
// The short form still respects the neutral and blur exclusions.
|
||||
'Two Length Neutral Edge',
|
||||
'Two Length Blurred 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'));
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
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>
|
||||
<article data-case="Two Length Edge"><h3>Two Length Edge</h3></article>
|
||||
<article data-case="Two Length Trailing Inset Edge"><h3>Two Length Trailing Inset 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>
|
||||
<article data-case="Two Length Neutral Edge"><h3>Two Length Neutral Edge</h3></article>
|
||||
<article data-case="Two Length Blurred Edge"><h3>Two Length Blurred 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; }
|
||||
|
||||
/* box-shadow takes <length>{2,4}: blur and spread are optional and default to
|
||||
0, so these paint the same stripe as the four-length forms above. */
|
||||
[data-case="Two Length Edge"] { box-shadow: inset 4px 0 var(--brand-accent); }
|
||||
[data-case="Two Length Trailing Inset Edge"] { box-shadow: 0 5px #6366f1 inset; }
|
||||
[data-case="Two Length Neutral Edge"] { box-shadow: inset 4px 0 #000; }
|
||||
[data-case="Two Length Blurred Edge"] { box-shadow: inset 4px 0 5px var(--brand-accent); }
|
||||
|
||||
/* Commented-out rules are not live CSS.
|
||||
[data-case="Commented Out Edge"] { box-shadow: inset 4px 0 0 var(--brand-accent); }
|
||||
*/
|
||||
</style>
|
||||
@@ -53,9 +53,7 @@ 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() {
|
||||
@@ -566,132 +564,6 @@ 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...
|
||||
|
||||
@@ -74,16 +74,6 @@ describe('live-accept — marker search must ignore Impeccable state', () => {
|
||||
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']);
|
||||
|
||||
@@ -660,270 +660,7 @@ 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 || '';
|
||||
@@ -1074,61 +811,6 @@ 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;
|
||||
}
|
||||
|
||||
+11
-223
@@ -27,10 +27,6 @@ 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);
|
||||
|
||||
@@ -1394,36 +1390,6 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
|
||||
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'));
|
||||
@@ -1465,92 +1431,6 @@ async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writePa
|
||||
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) {
|
||||
const text = String(markup || '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
@@ -1683,9 +1563,6 @@ export async function runAgentLoop({
|
||||
signal,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
progressive = false,
|
||||
progressiveDelayMs = 0,
|
||||
progressiveInitialCount = 1,
|
||||
atomicDelayMs = 0,
|
||||
wrapTarget = { classes: 'hero-title', tag: 'h1' },
|
||||
steerSourceFile,
|
||||
@@ -1807,91 +1684,16 @@ export async function runAgentLoop({
|
||||
// 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 });
|
||||
}
|
||||
let output = normalizeVariantOutput(
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
|
||||
wrapInfo,
|
||||
);
|
||||
if (atomicDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
|
||||
}
|
||||
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
|
||||
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}`);
|
||||
}
|
||||
@@ -1899,27 +1701,13 @@ export async function runAgentLoop({
|
||||
// 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 publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
await writeSvelteComponentVariants({ 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 });
|
||||
await writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} 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}`);
|
||||
|
||||
@@ -236,9 +236,6 @@ export async function bootFixtureSession({
|
||||
prepareTmp,
|
||||
log = () => {},
|
||||
trace = () => {},
|
||||
progressive = false,
|
||||
progressiveDelayMs = 0,
|
||||
progressiveInitialCount = 1,
|
||||
atomicDelayMs = 0,
|
||||
keepTmp = false,
|
||||
}) {
|
||||
@@ -324,18 +321,11 @@ export async function bootFixtureSession({
|
||||
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);
|
||||
agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]);
|
||||
}
|
||||
|
||||
const scheme = runtime.scheme || 'http';
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe('live reference authoring contract', () => {
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
codexLiveMd,
|
||||
/<\/?(codex|live-progressive)>/,
|
||||
/<\/?codex>/,
|
||||
'provider block tags should not leak into compiled Codex live reference',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
@@ -157,55 +157,17 @@ describe('live reference authoring contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gives progressive delivery to the harnesses that opt in, and only those', () => {
|
||||
|
||||
it('routes every helper command through the per-provider scripts path', () => {
|
||||
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.
|
||||
// A recipe that hardcodes `.agents/skills/...` is only correct for the Codex
|
||||
// repo-skills bundle. Every other harness would be told to run the helper
|
||||
// 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', () => {
|
||||
|
||||
@@ -80,21 +80,6 @@ describe('live-session-store', () => {
|
||||
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({
|
||||
|
||||
@@ -5,10 +5,6 @@ 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,
|
||||
@@ -216,75 +212,4 @@ describe('Nuxt Vue component preview', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user