diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs
index de9044441..c2663c60d 100644
--- a/scripts/test-suites.mjs
+++ b/scripts/test-suites.mjs
@@ -125,6 +125,8 @@ export const SUITES = {
'tests/live-browser-session.test.mjs',
'tests/live-browser-source.test.mjs',
'tests/live-benchmark.test.mjs',
+ 'tests/live-generation-preflight.test.mjs',
+ 'tests/live-generation-publisher.test.mjs',
'tests/live-commit-manual-edits.test.mjs',
'tests/live-completion.test.mjs',
'tests/live-copy-edit-agent.test.mjs',
@@ -141,11 +143,13 @@ export const SUITES = {
'tests/live-manual-edits-buffer.test.mjs',
'tests/live-poll.test.mjs',
'tests/live-poll-stream.test.mjs',
+ 'tests/live-provider-benchmark.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-target-context.test.mjs',
+ 'tests/live-vue-component.test.mjs',
'tests/live-wrap.test.mjs',
'tests/live-wrap-buffer-aware.test.mjs',
],
diff --git a/skill/reference/live.md b/skill/reference/live.md
index 715bb59f4..7d66da954 100644
--- a/skill/reference/live.md
+++ b/skill/reference/live.md
@@ -17,18 +17,25 @@ Execute in order. No step skipped, no step reordered.
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=`.
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; plan three distinct directions; deliver variants using the harness policy below; `--reply done`; poll again.
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 a cleanup owner runs `live-complete.mjs --id EVENT_ID`; Codex delegates that cleanup and resumes the foreground poll immediately, while synchronous harnesses finish 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.
- **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**: the main thread is the **foreground poll supervisor**. Keep the poll command itself in a yielded foreground exec session and retain its session id; do not suffix it with `&`. A yielded foreground process continues while other tool calls run, whereas a traditional shell-backgrounded child may be reaped when its shell exits. On `generate`, spawn one generation subagent/worker, give it the event plus scaffold, then poll again immediately in the main thread. The worker publishes variants and posts the generation reply; the supervisor remains available for early Accept/Discard and the next Go. Do not put the poll itself in a subagent or a fire-and-forget background shell: browser control events must return to the main thread immediately.
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
+Generation delivery policy:
+- **Default (Claude Code, Cursor, and other harnesses):** keep the established atomic single-edit delivery unless that harness has independently demonstrated that progressive tool calls are faster and reliable. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
+
+
+- **Codex progressive override:** 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 manifests only with the final set. The browser makes every arrived variant immediately reviewable and acceptable; Accept/Discard durably cancel unfinished revisions.
+
+
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`. For Operate/Read surfaces load `operate.md`; Persuade/Experience surfaces use SKILL.md's mode guidance plus `new-work.md` when the variant invents identity (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`. For Operate/Read surfaces load `operate.md`; Persuade/Experience surfaces use SKILL.md's mode guidance plus `new-work.md` when the variant invents identity (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//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 `', 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 };
+}
diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs
index affba67c9..8cc904efa 100644
--- a/skill/scripts/live/session-store.mjs
+++ b/skill/scripts/live/session-store.mjs
@@ -3,6 +3,13 @@ import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } 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);
@@ -38,7 +45,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,
@@ -119,6 +129,14 @@ function baseSnapshot(id) {
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
+ generationPhase: null,
+ generationTimings: {},
+ generationEpoch: 1,
+ publishedRevision: 0,
+ deliveredVariants: {},
+ generationCanceled: false,
+ generationCanceledAt: null,
+ cancelReason: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
@@ -158,6 +176,8 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ generationTimings: { ...(snapshot.generationTimings || {}) },
+ deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
@@ -170,14 +190,66 @@ 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);
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
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);
+ 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,7 +266,7 @@ 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;
}
@@ -215,6 +287,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
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;
@@ -243,6 +318,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 +338,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;
diff --git a/skill/scripts/live/source-lock.mjs b/skill/scripts/live/source-lock.mjs
new file mode 100644
index 000000000..dd82989bd
--- /dev/null
+++ b/skill/scripts/live/source-lock.mjs
@@ -0,0 +1,56 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
+
+const STALE_LOCK_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);
+ let fd;
+ while (fd === undefined) {
+ clearStaleLock(lockPath);
+ try {
+ fd = fs.openSync(lockPath, 'wx');
+ fs.writeFileSync(fd, JSON.stringify({ owner, pid: process.pid, at: Date.now(), file: path.resolve(cwd, file) }) + '\n');
+ } 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())));
+ }
+ }
+
+ try {
+ return fn();
+ } finally {
+ try { if (fd !== undefined) fs.closeSync(fd); } catch {}
+ try { fs.unlinkSync(lockPath); } catch {}
+ }
+}
+
+function sleepSync(ms) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+function clearStaleLock(lockPath) {
+ try {
+ const stat = fs.statSync(lockPath);
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.unlinkSync(lockPath);
+ } catch {}
+}
diff --git a/skill/scripts/live/vue-component.mjs b/skill/scripts/live/vue-component.mjs
new file mode 100644
index 000000000..c8f4d0825
--- /dev/null
+++ b/skill/scripts/live/vue-component.mjs
@@ -0,0 +1,343 @@
+/**
+ * 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';
+
+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, 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
+ ? `\n\n`
+ : '';
+ return `${props}\n${markup.trim()}\n\n\n\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(/]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
+ const style = text.match(/'];
+ 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;
+}
+
+function parseStaticAttrs(attrs) {
+ const out = new Map();
+ const re = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
+ let match;
+ while ((match = re.exec(attrs))) {
+ out.set(match[1], {
+ raw: match[0],
+ value: match[3],
+ quote: match[2],
+ start: match.index,
+ end: match.index + match[0].length,
+ });
+ }
+ return out;
+}
+
+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 and put variant CSS in
diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs
index 45c84d1dc..35c37b7de 100644
--- a/tests/live-browser-regression.test.mjs
+++ b/tests/live-browser-regression.test.mjs
@@ -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,
@@ -841,6 +841,42 @@ 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('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,
diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs
index d03457908..395092703 100644
--- a/tests/live-browser-source.test.mjs
+++ b/tests/live-browser-source.test.mjs
@@ -8,8 +8,21 @@ const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
describe('live-browser source contracts', () => {
+ 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('if (!hasAnnotations) await sendEvent(basePayload);');
+ 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');
@@ -20,7 +33,7 @@ describe('live-browser source contracts', () => {
);
assert.match(
CAPTURE_AND_EMIT_SOURCE,
- /if \(hasAnnotations\) \{\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
+ /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',
);
});
@@ -303,7 +316,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;/,
@@ -327,8 +340,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\(\);/,
@@ -337,15 +350,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,
@@ -411,4 +424,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',
+ );
+ });
});
diff --git a/tests/live-e2e-agent-output.test.mjs b/tests/live-e2e-agent-output.test.mjs
index d988c10b9..0e9fc203d 100644
--- a/tests/live-e2e-agent-output.test.mjs
+++ b/tests/live-e2e-agent-output.test.mjs
@@ -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(
'Title
',
diff --git a/tests/live-e2e-llm-agent.test.mjs b/tests/live-e2e-llm-agent.test.mjs
index 53d8e98eb..3ec82f3b5 100644
--- a/tests/live-e2e-llm-agent.test.mjs
+++ b/tests/live-e2e-llm-agent.test.mjs
@@ -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: 'One
', 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 = 'One
';
+ 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: 'Changed
', 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(
{
diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs
index 0d120bc49..f41d53c93 100644
--- a/tests/live-e2e.test.mjs
+++ b/tests/live-e2e.test.mjs
@@ -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';
@@ -45,6 +45,7 @@ import {
editTextLeaf,
drawAnnotationPinAndStroke,
getVisibleVariant,
+ installLiveQueryHelpers,
pickElement,
runLiveChromeBottomBarSmoke,
waitForApplyDockHidden,
@@ -220,7 +221,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 +315,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 +330,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 +351,8 @@ for (const { name, fixture } of fixtures) {
}
}
if (svelteComponentSession) {
- assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /',
+ '',
+ ].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 }) {
+ 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: 1,
+ phase: 'cycling',
+ reason: 'variants_progress',
+ arrivedVariants,
+ expectedVariants: event.count,
+ sourceFile: wrapInfo.sourceFile || wrapInfo.file,
+ previewFile: wrapInfo.file,
+ previewMode,
+ }),
+ signal,
+ });
}
function variantMarkupHasVisibleContent(markup) {
@@ -1507,6 +1671,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 +1699,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 +1749,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 +1767,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 +1788,142 @@ 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 (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 +2023,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
+ sourceEventType: 'accept',
id: event.id,
file: acceptResult.file,
message: acceptResult.error,
@@ -1769,6 +2053,7 @@ export async function runAgentLoop({
body: JSON.stringify({
token,
type: completionType,
+ sourceEventType: 'discard',
id: event.id,
file: discardResult.file,
message: discardResult.error,
@@ -1787,6 +2072,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));
diff --git a/tests/live-e2e/agents/llm-agent.mjs b/tests/live-e2e/agents/llm-agent.mjs
index 4bf854730..fe9248cd9 100644
--- a/tests/live-e2e/agents/llm-agent.mjs
+++ b/tests/live-e2e/agents/llm-agent.mjs
@@ -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;
diff --git a/tests/live-e2e/session.mjs b/tests/live-e2e/session.mjs
index 0ee220378..bb5b34192 100644
--- a/tests/live-e2e/session.mjs
+++ b/tests/live-e2e/session.mjs
@@ -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';
@@ -56,6 +56,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 +65,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;
@@ -205,7 +221,19 @@ export async function stopDevServer(child) {
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
* @param {(msg: string) => void} [opts.log]
*/
-export async function bootFixtureSession({ name, fixture, browser, agent, wrapTarget, log = () => {} }) {
+export async function bootFixtureSession({
+ name,
+ fixture,
+ browser,
+ agent,
+ wrapTarget,
+ log = () => {},
+ trace = () => {},
+ progressive = false,
+ progressiveDelayMs = 0,
+ progressiveInitialCount = 1,
+ atomicDelayMs = 0,
+}) {
const runtime = fixture.runtime;
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
@@ -233,30 +261,38 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
try {
const startedAt = Date.now();
+ 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)}`);
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({
+ const loopOptions = {
tmp,
scriptsDir: SCRIPTS_DIR,
port: live.port,
@@ -264,10 +300,19 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
agent,
wrapTarget,
signal: agentAbort.signal,
- log: (m) => log('[agent] ' + m),
+ 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 +328,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 {
diff --git a/tests/live-e2e/ui.mjs b/tests/live-e2e/ui.mjs
index 63a01b0ae..db9d60fb5 100644
--- a/tests/live-e2e/ui.mjs
+++ b/tests/live-e2e/ui.mjs
@@ -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: document.documentElement.dataset.impeccableLiveState || 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.
@@ -578,7 +594,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 +613,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__,
diff --git a/tests/live-generation-preflight.test.mjs b/tests/live-generation-preflight.test.mjs
new file mode 100644
index 000000000..ea84e5520
--- /dev/null
+++ b/tests/live-generation-preflight.test.mjs
@@ -0,0 +1,87 @@
+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', () => {
+ const calls = [];
+ const result = runGenerationPreflight({
+ type: 'generate',
+ id: 'session-3',
+ count: 1,
+ element: { classes: ['hero'] },
+ }, {
+ scriptsDir: SCRIPTS_DIR,
+ cwd: '/tmp/example',
+ execFileSyncImpl(file, args, options) {
+ calls.push({ file, args, options });
+ return '{"file":"src/App.jsx","insertLine":12}\n';
+ },
+ });
+
+ 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', () => {
+ const result = runGenerationPreflight({
+ type: 'generate',
+ id: 'session-4',
+ count: 3,
+ element: { tagName: 'DIV' },
+ }, { scriptsDir: SCRIPTS_DIR });
+
+ assert.deepEqual(result, { ok: false, skipped: true, reason: 'insufficient_locator' });
+});
diff --git a/tests/live-generation-publisher.test.mjs b/tests/live-generation-publisher.test.mjs
new file mode 100644
index 000000000..a402219e3
--- /dev/null
+++ b/tests/live-generation-publisher.test.mjs
@@ -0,0 +1,364 @@
+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, '');
+ store = createLiveSessionStore({ cwd: tmp, sessionId: 'abc12345' });
+ store.appendEvent({
+ type: 'generate',
+ id: 'abc12345',
+ generationEpoch: 1,
+ action: 'polish',
+ count: 3,
+ element: { outerHTML: 'Original' },
+ });
+ });
+
+ 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, '');
+ const result = publishGenerationArtifact({
+ id: 'abc12345',
+ epoch: 1,
+ sourceFile: source,
+ artifactFile: artifact,
+ expectedSourceHash: sha256(before),
+ expectedVariants: 3,
+ cwd: tmp,
+ });
+
+ assert.equal(result.ok, true);
+ 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, '');
+ 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, '');
+ 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 = '';
+ 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('', 'Second
');
+ 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('rejects later source revisions that restyle an already reviewable variant', () => {
+ const firstSource = '';
+ 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, '{title}
\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: '{title}
',
+ 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`), `Stub ${variant}\n`);
+ }
+ store = createLiveSessionStore({ cwd: tmp, sessionId: 'svelte123' });
+ store.appendEvent({
+ type: 'generate',
+ id: 'svelte123',
+ generationEpoch: 1,
+ action: 'polish',
+ count: 3,
+ element: { outerHTML: 'Original
' },
+ });
+ });
+
+ 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'), 'Stub 1\n');
+
+ writeFileSync(join(tmp, result.componentDir, 'v1.svelte'), 'Prepared only\n');
+ assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), 'Stub 1\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'), 'First live variant\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'), 'First live variant\n');
+ assert.equal(readFileSync(source, 'utf-8'), '{title}
\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: 'First live variant\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: 'Silently changed first variant\n',
+ 2: 'Second live variant\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: 'First live variant\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: 'Second live variant\n',
+ 3: 'Third live variant\n',
+ },
+ });
+
+ assert.equal(result.ok, true);
+ assert.equal(result.arrivedVariants, 3);
+ assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), 'First live variant\n');
+ assert.equal(readFileSync(join(componentDir, 'v2.svelte'), 'utf-8'), 'Second live variant\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: 'Too late\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,
+ });
+ }
+});
diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs
index bf0760fb5..528c49a89 100644
--- a/tests/live-inject.test.mjs
+++ b/tests/live-inject.test.mjs
@@ -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 = `\n \n\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: '',
+ 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: '',
+ 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);
+ });
});
diff --git a/tests/live-poll.test.mjs b/tests/live-poll.test.mjs
index e4497cede..366a1cccb 100644
--- a/tests/live-poll.test.mjs
+++ b/tests/live-poll.test.mjs
@@ -25,6 +25,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', () => {
diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs
index 2fa1fff52..8e0d03b3f 100644
--- a/tests/live-reference.test.mjs
+++ b/tests/live-reference.test.mjs
@@ -129,6 +129,16 @@ describe('live reference authoring contract', () => {
/sandbox_permissions: "require_escalated"/,
'Codex-only sandbox guidance should not appear in Claude live reference',
);
+ assert.match(
+ codexLiveMd,
+ /Codex progressive override/,
+ 'Codex live reference should progressively deliver the first reviewable variant',
+ );
+ assert.doesNotMatch(
+ claudeLiveMd,
+ /Codex progressive override|first-reviewable milestone/,
+ 'Claude live reference should retain the atomic path without Codex-specific delivery instructions',
+ );
});
it('keeps live preview CSS guidance capability-mode driven', () => {
diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs
index ec0e22bdc..590e2f459 100644
--- a/tests/live-server.test.mjs
+++ b/tests/live-server.test.mjs
@@ -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'), 'Preview
\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++) {
@@ -2142,6 +2167,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 +2215,24 @@ 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 res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2195,8 +2241,10 @@ colors: {}
type: 'checkpoint',
id: 'a1b2c3d7',
phase: 'cycling',
+ reason: 'variants_ready',
revision: 2,
owner: 'browser-a',
+ expectedVariants: 3,
arrivedVariants: 3,
visibleVariant: 2,
paramValues: { density: 'packed' },
@@ -2214,6 +2262,113 @@ 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.all_variants_ready?.at);
+ assert.ok(snapshot.generationTimings.first_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('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',
+ }),
+ });
+ 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"/);
+ controller.abort();
});
it('redelivers an unacknowledged browser event after helper server restart', async () => {
diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs
index abfc0af3f..18da0fe92 100644
--- a/tests/live-session-store.test.mjs
+++ b/tests/live-session-store.test.mjs
@@ -62,6 +62,51 @@ describe('live-session-store', () => {
assert.equal(active[0].id, 'session-a');
});
+ 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: '', 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({
@@ -284,4 +329,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 });
+ });
});
diff --git a/tests/live-vue-component.test.mjs b/tests/live-vue-component.test.mjs
new file mode 100644
index 000000000..5aec55cfb
--- /dev/null
+++ b/tests/live-vue-component.test.mjs
@@ -0,0 +1,212 @@
+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, [
+ '',
+ ' ',
+ ' Hello {{ user.name }}
',
+ ' ',
+ '',
+ '',
+ '',
+ '',
+ ].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: [' Hello {{ user.name }}
'],
+ 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, //);
+ 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, 'Client app
\n');
+
+ const result = scaffoldVueComponentSession({
+ id: 'clientsrc',
+ count: 1,
+ sourceFile: 'client/pages/index.vue',
+ sourceStartLine: 1,
+ sourceEndLine: 1,
+ originalLines: ['Client app
'],
+ 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: [' Hello {{ user.name }}
'],
+ cwd: tmp,
+ });
+ writeFileSync(join(tmp, result.componentDir, 'v1.vue'), [
+ '',
+ '',
+ ' Welcome {{ name }}
',
+ '',
+ '',
+ '',
+ ].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('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: [' Hello {{ user.name }}
'],
+ 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: [' Hello {{ user.name }}
'],
+ cwd: tmp,
+ });
+ const store = createLiveSessionStore({ cwd: tmp, sessionId: 'vue12345' });
+ store.appendEvent({
+ type: 'generate',
+ id: 'vue12345',
+ generationEpoch: 1,
+ count: 3,
+ action: 'polish',
+ element: { outerHTML: 'Hello Paul
' },
+ });
+ 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'), 'First
\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 });
+ 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);
+ });
+});
diff --git a/tests/live-wrap.test.mjs b/tests/live-wrap.test.mjs
index d97b29299..ec049762b 100644
--- a/tests/live-wrap.test.mjs
+++ b/tests/live-wrap.test.mjs
@@ -780,6 +780,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' }];
+---
+
+ {results[0].title}
+ {results[1].title}
+`;
+ 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