From 2106a2881f3c235bc381a8eabb2b0c28546c471b Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 12 Jul 2026 17:54:50 -0700 Subject: [PATCH] Improve Live progressive responsiveness Add transactional progressive publication, durable cancellation, responsive accept cleanup, and framework-safe Svelte and Nuxt previews.\n\nAI-assisted: OpenAI Codex. --- scripts/test-suites.mjs | 4 + skill/reference/live.md | 71 ++- skill/scripts/live-accept.mjs | 109 +++- skill/scripts/live-browser.js | 236 ++++++-- skill/scripts/live-inject.mjs | 146 ++++- skill/scripts/live-poll.mjs | 5 +- skill/scripts/live-publish.mjs | 36 ++ skill/scripts/live-server.mjs | 173 +++++- skill/scripts/live-wrap.mjs | 76 ++- skill/scripts/live/generation-preflight.mjs | 91 +++ skill/scripts/live/generation-publisher.mjs | 549 ++++++++++++++++++ skill/scripts/live/session-store.mjs | 86 ++- skill/scripts/live/source-lock.mjs | 56 ++ skill/scripts/live/vue-component.mjs | 343 +++++++++++ tests/framework-fixtures.test.mjs | 14 + tests/framework-fixtures/README.md | 1 + .../nuxt-vite7/files/app.vue | 10 - .../nuxt-vite7/files/app/app.vue | 3 + .../files/{ => app}/pages/index.vue | 0 .../nuxt-vite7/files/nuxt.config.ts | 1 + .../nuxt-vite7/fixture.json | 32 +- .../files/src/routes/+page.svelte | 7 + tests/live-browser-regression.test.mjs | 42 +- tests/live-browser-source.test.mjs | 41 +- tests/live-e2e-agent-output.test.mjs | 12 +- tests/live-e2e-llm-agent.test.mjs | 63 ++ tests/live-e2e.test.mjs | 331 ++++++++++- tests/live-e2e/agent.mjs | 311 +++++++++- tests/live-e2e/agents/llm-agent.mjs | 101 +++- tests/live-e2e/session.mjs | 59 +- tests/live-e2e/ui.mjs | 29 +- tests/live-generation-preflight.test.mjs | 87 +++ tests/live-generation-publisher.test.mjs | 364 ++++++++++++ tests/live-inject.test.mjs | 69 ++- tests/live-poll.test.mjs | 9 + tests/live-reference.test.mjs | 10 + tests/live-server.test.mjs | 155 +++++ tests/live-session-store.test.mjs | 67 +++ tests/live-vue-component.test.mjs | 212 +++++++ tests/live-wrap.test.mjs | 28 + 40 files changed, 3833 insertions(+), 206 deletions(-) create mode 100644 skill/scripts/live-publish.mjs create mode 100644 skill/scripts/live/generation-preflight.mjs create mode 100644 skill/scripts/live/generation-publisher.mjs create mode 100644 skill/scripts/live/source-lock.mjs create mode 100644 skill/scripts/live/vue-component.mjs delete mode 100644 tests/framework-fixtures/nuxt-vite7/files/app.vue create mode 100644 tests/framework-fixtures/nuxt-vite7/files/app/app.vue rename tests/framework-fixtures/nuxt-vite7/files/{ => app}/pages/index.vue (100%) create mode 100644 tests/live-generation-preflight.test.mjs create mode 100644 tests/live-generation-publisher.test.mjs create mode 100644 tests/live-vue-component.test.mjs 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\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(/]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || ''; + return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] }; +} + +function restoreVueExpressions(markup, contract) { + let out = String(markup || ''); + for (const entry of contract || []) { + out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder); + } + return out; +} + +export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) { + const sourcePath = resolveInside(cwd, manifest.sourceFile); + const componentDir = resolveInside(cwd, manifest.componentDir); + const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'vue-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8')); + if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase }; + if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) { + return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase }; + } + + const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || ''); + const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract) + .split('\n') + .map((line) => line.trim() ? indent + line.trimStart() : ''); + let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)]; + const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim())); + if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss); + fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8'); + retireVueComponentSession(manifest.id, cwd); + return { handled: true, ...resultBase }; +} + +function appendVueStyle(lines, cssLines) { + let close = -1; + for (let index = lines.length - 1; index >= 0; index--) { + if (/<\/style\s*>/.test(lines[index])) { close = index; break; } + } + const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')]; + if (close < 0) return [...lines, '', '']; + 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 ', + 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'), '\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: '
Hero
', 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, [ + '', + '', + '', + '', + ].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, /