diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4a80a..8d175ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - run: pnpm audit --audit-level=high + - name: Verify browser harness build cleanup + run: node --test scripts/fixtures/replay-process.test.mjs + - run: pnpm exec nx run-many -t lint test build typecheck --exclude domscribe-test-fixtures - name: Update coverage badge @@ -131,19 +134,35 @@ jobs: - id: react-npm-vite7 framework: react package-manager: npm - vite: '7.3.7' + version-args: '--vite-version=7.3.7' - id: react-pnpm-vite5 framework: react package-manager: pnpm - vite: '5.4.21' + version-args: '--vite-version=5.4.21' - id: vue-npm-vite7 framework: vue package-manager: npm - vite: '7.3.7' + version-args: '--vite-version=7.3.7' - id: vue-pnpm-vite5 framework: vue package-manager: pnpm - vite: '5.4.21' + version-args: '--vite-version=5.4.21' + - id: next15-npm-webpack + framework: next + package-manager: npm + version-args: '--framework-version=15.5.28' + - id: next16-pnpm-turbopack + framework: next + package-manager: pnpm + version-args: '--framework-version=16.4.3' + - id: nuxt3-npm + framework: nuxt + package-manager: npm + version-args: '--framework-version=3.21.11' + - id: nuxt4-pnpm + framework: nuxt + package-manager: pnpm + version-args: '--framework-version=4.6.2' steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 @@ -163,7 +182,7 @@ jobs: node scripts/verify-instance-replay.mjs --framework=${{ matrix.framework }} --package-manager=${{ matrix.package-manager }} - --vite-version=${{ matrix.vite }} + ${{ matrix.version-args }} --report-dir=${{ runner.temp }}/portable-replay-evidence - name: Retain replay evidence if: always() diff --git a/docs/agent-targets-and-replay.md b/docs/agent-targets-and-replay.md index 5a6fd3e..dff2493 100644 --- a/docs/agent-targets-and-replay.md +++ b/docs/agent-targets-and-replay.md @@ -1,6 +1,6 @@ # Exact instance selection and portable replay -These APIs are on the local `codex/instance-selection-replay` feature branch. They are not part of the published 0.6.1 packages yet. +These APIs are in the repository following PR #77. They are not part of the published 0.6.1 packages yet. A `data-ds` ID identifies a source element. Repeated rows can share it. An `instanceId` identifies one live DOM instance and has a strict, transient browser selector. Its `locator` describes the target independently of that session. @@ -16,7 +16,7 @@ The response contains `status`, `targets`, `total`, and `browserConnected`. Each To inspect an instance, send `{"instanceId":""}`. To locate a capture in a new session, send `{"locator":}`. `domscribe.query.bySource` also accepts an optional `instanceId` or `locator` and returns `selectionStatus`, `targets` and `total` alongside its existing fields. -Only `resolved` identifies one exact target and returns its available runtime context. `listed` is discovery, not a selection. `ambiguous`, `not_found`, `stale`, `wrong_page`, `not_ready`, `unsupported`, `ambiguous_browser`, and `unavailable` are explicit stopping conditions. Reconnect/retry readiness failures or refine ambiguous targets; do not choose the first candidate. Keep one browser session connected to the relay for these queries. +Only `resolved` identifies one exact target and returns its available runtime context. `listed` is discovery, not a selection. `ambiguous`, `not_found`, `stale`, `wrong_page`, `not_ready`, `unsupported`, `ambiguous_browser`, and `unavailable` are explicit stopping conditions. Reconnect/retry readiness failures or refine ambiguous targets; do not choose the first candidate. Keep one browser session connected to the relay for these queries. After HMR, the manifest can briefly lag newly rendered source IDs; retry source resolution while updates settle instead of substituting an old mapping. Handles survive DOM moves. Removed/recycled targets and changed source or identity constraints invalidate them. Never persist a handle as the only identity for a later session. Browser drivers still check visibility, enabled state, overlays and normal actionability; discovery does not imply that an element can be clicked immediately. @@ -71,6 +71,29 @@ NX_DAEMON=false pnpm exec nx run-many -t build,sync-dist \ node scripts/verify-instance-replay.mjs node scripts/verify-instance-replay.mjs --framework=vue --package-manager=pnpm \ --vite-version=5.4.21 --report-dir=/tmp/vue-replay-evidence +node scripts/verify-instance-replay.mjs --framework=next --package-manager=pnpm \ + --framework-version=16.4.3 --report-dir=/tmp/next-replay-evidence +node scripts/verify-instance-replay.mjs --framework=nuxt --package-manager=npm \ + --framework-version=3.21.11 --report-dir=/tmp/nuxt-replay-evidence ``` -The harness installs local tarballs into a temporary consumer, uses the fixture project's existing Playwright dependency and installed Chromium, persists an annotation, closes the browser context, reorders rows in a fresh context, and replays the same four UI steps through the relay. It also verifies source-only ambiguity, exact runtime props, committed editor state after real typing/checking, stale-handle refusal, ambiguity stopping, application page errors, and production DOM instrumentation removal. Evidence is retained in the printed temporary directory. The default remains React 18/npm/Vite 7. CI also verifies React 18/pnpm/Vite 5, Vue 3/npm/Vite 7, and Vue 3/pnpm/Vite 5, with at most two replay jobs running simultaneously. These are four representative combinations, not every framework/manager/version permutation. Each run checks the mapped framework source, actual installed versions, and shared local runtime; Vue uses compiled SFC components. Package installs use local tarballs, with explicit pnpm local-artifact overrides so unpublished branch artifacts cannot be replaced by registry copies. This is not evidence that the new APIs are in the published npm release. Logs and result JSON are retained on success and failure. Traces and screenshots are retained when available, with best-effort failure screenshots after browser startup. Production UI interactions are verified after instrumentation removal. Next/Nuxt browser coverage for the new APIs remains outstanding. +The harness installs local tarballs into a temporary consumer, uses the fixture project's existing Playwright dependency and installed Chromium, persists an annotation, closes the browser context, reorders rows in a fresh context, and replays the same four UI steps through the relay. Assertions cover exact mapped source/props, committed editor state after typing/checking, stale handles after unmount and across sessions, ambiguity refusal, application console/page/network failures, production instrumentation removal (including conditional editors), and correct production UI updates. + +CI runs eight representative combinations, with at most two replay jobs simultaneously: + +| Integration | Consumer versions | Package manager | +| --------------------------- | ------------------------- | --------------- | +| React/Vite | React18.3.1 / Vite7.3.7 | npm | +| React/Vite | React18.3.1 / Vite5.4.21 | pnpm | +| Vue/Vite | Vue3.5.44 / Vite7.3.7 | npm | +| Vue/Vite | Vue3.5.44 / Vite5.4.21 | pnpm | +| Next App Router / Webpack | Next15.5.28 / React19.3.1 | npm | +| Next App Router / Turbopack | Next16.4.3 / React19.3.1 | pnpm | +| Nuxt SSR | Nuxt3.21.11 / Vue3.5.44 | npm | +| Nuxt SSR | Nuxt4.6.2 / Vue3.5.44 | pnpm | + +The default remains React18/npm/Vite7. Use `--framework-version` for exact Next15/16 or Nuxt3/4 releases; use `--vite-version` for React/Vue consumers. Mixed or unsupported flags are rejected. Native SSR consumers use their framework integrations and normal hydration, not the Vite SPA integration. Nuxt excludes the runtime from Vite prebundling so its native plugin and consumer queries share one runtime on cold startup. Each run checks actual installed versions, expected framework source, and local runtime resolution. + +These are representative synthetic regression cases, not the full version/manager/router matrix or an adoption benchmark. Additional local testing on the Domscribe website (Next16.4.3 / React19.2.4) verified exact installation-tab source/state among six repeated instances, persisted replay after a fresh browser session, safe page-navigation failure, mobile navigation state/replay after reload, and controlled HMR identity changes without resurrecting retired handles. Its production build and UI also worked without instrumentation. This is one maintained app, not evidence for every router, virtualized list, framework version, or agent workflow advantage. Package installs use local tarballs, with explicit pnpm local-artifact overrides so unpublished artifacts cannot be replaced by registry copies. This is not evidence that the new APIs are in the published npm release. + +Logs and result JSON are retained in the printed temporary directory on success and failure. `--report-dir` copies a compact evidence bundle. Traces and screenshots are retained when available, with best-effort failure screenshots after browser startup. Server startup and builds are bounded, and the harness stops its owned server process group and relay. diff --git a/docs/superpowers/plans/2026-10-25-next-nuxt-replay.md b/docs/superpowers/plans/2026-10-25-next-nuxt-replay.md new file mode 100644 index 0000000..7ac8ea8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-next-nuxt-replay.md @@ -0,0 +1,44 @@ +# Next and Nuxt Replay Verification Plan + +> **For agentic workers:** Use superpowers:executing-plans inline; obtain one independent whole-branch review. Steps use checkboxes. + +**Goal:** Close the Next/Nuxt exact-instance and portable replay browser gap, fixing reproduced integration defects with regression tests. + +**Architecture:** Extend the existing isolated local-tarball consumer harness and equivalent customer-editor fixtures. Exercise native Next App Router SSR/hydration (15 Webpack, 16 Turbopack) and Nuxt SSR/hydration (3 and 4), with the same exact-source/props/state, fresh-session replay and production assertions. Keep framework setup and commands in a small helper. + +**Tech Stack:** Existing Node/Playwright harness, Next15.5.28/16.4.3, React19.3.1, Nuxt3.21.11/4.6.2, Vue3.5.44; npm/pnpm. + +**Spec:** `docs/superpowers/specs/2026-10-25-instance-selection-replay-design.md` and the user's authorized continuation of framework and realistic-app verification. + +## Constraints and review focus + +- Reuse isolated worktree on branch codex/next-nuxt-portable-replay, based on freshly fetched main2e0d92d. Preserve user checkouts and lockfiles. +- Keep default React/Vite verification working. No publication/deployment, credentials, settings or CI Action product work. +- Assert actual installed framework versions, expected source files, and committed component state after hydration; no framework-state injection. +- pnpm local-artifact overrides remain explicit; do not claim unmodified published-package success. +- Bound SSR startup/build/browser operations and clean owned process trees, including failures. Retain logs/screenshots. +- Verify conditional production elements and production UI actions; a replay success alone is insufficient. +- Add navigation/lifecycle evidence where it exposes a real gap. Report unsupported paths honestly. + +## Task 1: Native SSR consumers + +- [x] Baseline React consumer passes; current Next invocation fails because only React/Vue are accepted (RED). +- [x] Extend CLI with next/nuxt and exact framework-version, native config/source/commands, bounded builds and cleanup. Share fixture UI. +- [x] Run fresh Next15/npm, Next16/pnpm, Nuxt3/npm, Nuxt4/pnpm consumers. Record actual versions, mapped sources, all outcomes. Fix product failures only after reproducing them. + +## Task 2: Regression delivery and readiness + +- [x] Add representative bounded CI cases and accurate docs for passing paths; rerun default React/Vue regressions. +- [x] Run affected configured checks and obtain independent review; address material findings with regressions. +- [x] Push focused PR, inspect latest CI, and record status in the continuity ledger. +- [x] Update continuity ledger with exact evidence and remaining realistic-app/lifecycle gaps. Do not claim readiness or measured user advantage prematurely. + +## Execution evidence + +- Old harness Next invocation rejected (RED). Original React baseline passed seven checks. +- Native Next15/npm, Next16/pnpm and Nuxt4/pnpm initially passed; Nuxt3/npm consistently failed cold readiness. Diagnostic browser showed native Nuxt plugin importing raw runtime ESM and app query importing optimized runtime, with separate singletons. Isolated exclude hypothesis passed on a cold cache. +- Regression unit initially failed (1 failed /32 passed). Nuxt module now preserves existing optimizeDeps excludes and adds runtime exclusion idempotently after its dev guard. Configured Nuxt test/lint/build passed;41 tests. Fresh Nuxt3 cold consumer then passed eight checks. One attempted install hit /tmp inode exhaustion; retained failure evidence and cleaned completed temporary dependencies before fresh success. +- All eight representative consumer combinations passed eight browser checks each. No console/page/network errors. Latest process-helper reruns: Next16/pnpm, Nuxt3/npm and Vue/pnpm also passed eight each. +- Full uncached configured unit suite passed2114 tests in11 projects (test dependencies already built; excluded dependency tasks to avoid concurrent artifact mutation). +- Independent review found one P2: direct-child build timeout could leave workers. Reproduced surviving worker on timeout and cleaned it; replaced with detached owned-group async build, TERM/KILL deadline and retained logs. Two process regression tests pass; focused re-review has no material findings. Applied bounded polling to avoid a termination-signal race in its Linux test. +- PR78 pushed with fresh CI; inspect current checks in the continuity ledger. Real-site verification passed four dev capture/replay/navigation checks, three production checks, and four controlled HMR/mobile checks. HMR manifest propagation required bounded source-resolution retry; documented this limit. All temporary website source/config changes were restored; no website commit or deployment. diff --git a/packages/domscribe-nuxt/src/module.spec.ts b/packages/domscribe-nuxt/src/module.spec.ts index db795f3..e8c053e 100644 --- a/packages/domscribe-nuxt/src/module.spec.ts +++ b/packages/domscribe-nuxt/src/module.spec.ts @@ -7,6 +7,7 @@ interface MockNuxt { options: { dev: boolean; rootDir: string; + vite: { optimizeDeps?: { exclude?: string[] } }; app: { head: { script: Array<{ innerHTML: string }>; @@ -94,6 +95,7 @@ function createMockNuxt(overrides?: Partial): MockNuxt { options: { dev: true, rootDir: '/test/project', + vite: {}, app: { head: { script: [], @@ -158,6 +160,22 @@ describe('domscribeModule', () => { }); describe('setup', () => { + it('keeps one runtime module identity for native plugin and consumer queries on cold Vite startup', async () => { + const nuxt = createMockNuxt({ + vite: { optimizeDeps: { exclude: ['existing-package'] } }, + }); + await callSetup({ relay: { autoStart: false } }, nuxt); + expect(nuxt.options.vite.optimizeDeps?.exclude).toEqual([ + 'existing-package', + '@domscribe/runtime', + ]); + await callSetup({ relay: { autoStart: false } }, nuxt); + expect(nuxt.options.vite.optimizeDeps?.exclude).toEqual([ + 'existing-package', + '@domscribe/runtime', + ]); + }); + describe('dev-mode guard', () => { it('should skip setup in production without force-transform', async () => { const nuxt = createMockNuxt({ dev: false }); diff --git a/packages/domscribe-nuxt/src/module.ts b/packages/domscribe-nuxt/src/module.ts index 8ef61b4..586e198 100644 --- a/packages/domscribe-nuxt/src/module.ts +++ b/packages/domscribe-nuxt/src/module.ts @@ -43,6 +43,15 @@ export const domscribeModule: NuxtModule = const debug = options.debug ?? false; const forceTransform = !!process.env.DOMSCRIBE_FORCE_TRANSFORM; + // Nuxt can import its native plugin's runtime as raw ESM while Vite + // prebundles an application's runtime import on a cold start. Those + // module copies have separate RuntimeManager singletons. Keep both + // imports on the same ESM path so queries see the initialized adapter. + const optimizeDeps = (nuxt.options.vite.optimizeDeps ??= {}); + optimizeDeps.exclude = [ + ...new Set([...(optimizeDeps.exclude ?? []), '@domscribe/runtime']), + ]; + // 1. Start relay to discover actual host/port. // Nuxt bypasses Vite's transformIndexHtml, so the Vite plugin's // HTML injection never fires. We start the relay here and inject diff --git a/scripts/fixtures/replay-consumer.mjs b/scripts/fixtures/replay-consumer.mjs index 9246c66..d2ccb85 100644 --- a/scripts/fixtures/replay-consumer.mjs +++ b/scripts/fixtures/replay-consumer.mjs @@ -1,7 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -export function writeReplayConsumer(consumer, framework) { +export function writeReplayConsumer(consumer, appFramework) { + const framework = + appFramework === 'next' + ? 'react' + : appFramework === 'nuxt' + ? 'vue' + : appFramework; const source = path.join(consumer, 'src'); fs.mkdirSync(source); const entry = framework === 'vue' ? 'main.js' : 'main.jsx'; @@ -96,4 +102,52 @@ createRoot(document.getElementById('root')).render(); `, ); } + if (appFramework === 'next') { + fs.rmSync(path.join(consumer, 'index.html')); + fs.rmSync(path.join(consumer, 'vite.config.mjs')); + const entry = path.join(source, 'main.jsx'); + fs.writeFileSync( + entry, + "'use client';\n" + + fs + .readFileSync(entry, 'utf8') + .replace("import{createRoot}from'react-dom/client';", '') + .replace( + 'if(import.meta.env.DEV)', + "if(typeof window!=='undefined' && process.env.NODE_ENV==='development')", + ) + .replace('function App()', 'export default function App()') + .replace( + "createRoot(document.getElementById('root')).render();", + '', + ), + ); + fs.mkdirSync(path.join(source, 'app')); + fs.writeFileSync( + path.join(source, 'app/page.jsx'), + "import App from '../main.jsx';export default function Page(){return }\n", + ); + fs.writeFileSync( + path.join(source, 'app/layout.jsx'), + 'export default function Layout({children}){return {children}}\n', + ); + fs.writeFileSync( + path.join(consumer, 'next.config.mjs'), + "import {withDomscribe} from '@domscribe/next';export default withDomscribe({overlay:true,relay:{port:0}})({reactStrictMode:true,experimental:{cpus:2}});\n", + ); + } else if (appFramework === 'nuxt') { + fs.rmSync(path.join(consumer, 'index.html')); + fs.rmSync(path.join(consumer, 'vite.config.mjs')); + fs.rmSync(path.join(source, 'main.js')); + fs.renameSync(path.join(source, 'App.vue'), path.join(source, 'app.vue')); + fs.mkdirSync(path.join(source, 'plugins')); + fs.writeFileSync( + path.join(source, 'plugins/evidence.client.js'), + "export default defineNuxtPlugin(async()=>{if(import.meta.dev){const {RuntimeManager}=await import('@domscribe/runtime');window.__runtime=RuntimeManager.getInstance();}});\n", + ); + fs.writeFileSync( + path.join(consumer, 'nuxt.config.mjs'), + "export default defineNuxtConfig({srcDir:'src/',compatibilityDate:'2026-10-25',devtools:{enabled:false},modules:['@domscribe/nuxt'],domscribe:{overlay:true,relay:{port:0}},app:{head:{title:'Instance replay regression'}}});\n", + ); + } } diff --git a/scripts/fixtures/replay-process.mjs b/scripts/fixtures/replay-process.mjs new file mode 100644 index 0000000..ed801c5 --- /dev/null +++ b/scripts/fixtures/replay-process.mjs @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; + +export function startOwnedProcess(command, args, options) { + return spawn(command, args, { ...options, detached: true }); +} + +export async function stopOwnedProcess(child, graceMs = 1000) { + if (!child?.pid) return; + try { + process.kill(-child.pid, 'SIGTERM'); + } catch {} + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, graceMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + // The leader may have exited before its workers. Always retire the group. + try { + process.kill(-child.pid, 'SIGKILL'); + } catch {} +} + +export async function runOwnedBuild( + command, + args, + { cwd, env, logPath, timeoutMs = 300000, graceMs = 1000 }, +) { + const log = fs.openSync(logPath, 'w'); + let child, timer; + try { + child = startOwnedProcess(command, args, { + cwd, + env, + stdio: ['ignore', log, log], + }); + await new Promise((resolve, reject) => { + let timedOut = false; + timer = setTimeout(async () => { + timedOut = true; + await stopOwnedProcess(child, graceMs); + reject(new Error(`Production build timed out after ${timeoutMs}ms`)); + }, timeoutMs); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (timedOut) return; + if (code === 0) resolve(); + else reject(new Error(`Production build failed: ${signal ?? code}`)); + }); + }); + } finally { + clearTimeout(timer); + await stopOwnedProcess(child, graceMs); + fs.closeSync(log); + } +} diff --git a/scripts/fixtures/replay-process.test.mjs b/scripts/fixtures/replay-process.test.mjs new file mode 100644 index 0000000..1ed71cb --- /dev/null +++ b/scripts/fixtures/replay-process.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runOwnedBuild } from './replay-process.mjs'; + +test('retains output for successful and failed builds', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'replay-build-test-')); + const logPath = path.join(cwd, 'build.log'); + try { + await runOwnedBuild(process.execPath, ['-e', 'console.log("built")'], { + cwd, + logPath, + }); + assert.match(fs.readFileSync(logPath, 'utf8'), /built/); + await assert.rejects( + runOwnedBuild( + process.execPath, + ['-e', 'console.error("broken");process.exit(2)'], + { cwd, logPath }, + ), + /build failed: 2/, + ); + assert.match(fs.readFileSync(logPath, 'utf8'), /broken/); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test( + 'deadline kills a TERM-ignoring build and its worker', + { timeout: 10000 }, + async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'replay-build-test-')); + const logPath = path.join(cwd, 'build.log'); + const marker = path.join(cwd, 'pids.json'); + const parent = ` + const {spawn}=require('node:child_process'),fs=require('node:fs'); + process.on('SIGTERM',()=>{}); + const worker=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"],{stdio:'ignore'}); + fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid,worker.pid])); + console.log('started');setInterval(()=>{},1000); + `; + const started = Date.now(); + try { + await assert.rejects( + runOwnedBuild(process.execPath, ['-e', parent], { + cwd, + logPath, + timeoutMs: 1000, + graceMs: 100, + }), + /build timed out/, + ); + assert( + Date.now() - started < 5000, + 'deadline must include bounded escalation', + ); + assert.match(fs.readFileSync(logPath, 'utf8'), /started/); + for (const pid of JSON.parse(fs.readFileSync(marker, 'utf8'))) { + let running = true; + const deadline = Date.now() + 1000; + while (running && Date.now() < deadline) { + try { + // A killed orphan can briefly remain a zombie until init reaps it. + running = !fs + .readFileSync(`/proc/${pid}/stat`, 'utf8') + .split(') ')[1] + .startsWith('Z'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + running = false; + } + if (running) await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal( + running, + false, + `process ${pid} must not survive the deadline`, + ); + } + } finally { + if (fs.existsSync(marker)) + for (const pid of JSON.parse(fs.readFileSync(marker, 'utf8'))) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + fs.rmSync(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/fixtures/replay-project.mjs b/scripts/fixtures/replay-project.mjs new file mode 100644 index 0000000..90c451f --- /dev/null +++ b/scripts/fixtures/replay-project.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; + +// Native framework CLIs run from the isolated consumer, never workspace aliases. +export function replayProject(framework, version, viteVersion) { + assert( + ['react', 'vue', 'next', 'nuxt'].includes(framework), + 'unsupported framework', + ); + const ssr = framework === 'next' || framework === 'nuxt'; + assert(ssr || !version, 'framework-version is only supported for next/nuxt'); + const frameworkVersion = + version ?? (framework === 'next' ? '16.4.3' : '3.21.11'); + if (ssr) { + assert( + (framework === 'next' ? /^(15|16)\.\d+\.\d+$/ : /^[34]\.\d+\.\d+$/).test( + frameworkVersion, + ), + 'framework-version must be an exact Next 15/16 or Nuxt 3/4 release', + ); + } + const ui = + framework === 'next' ? 'react' : framework === 'nuxt' ? 'vue' : framework; + const dependencies = + ui === 'react' + ? { + react: ssr ? '19.3.1' : '18.3.1', + 'react-dom': ssr ? '19.3.1' : '18.3.1', + } + : { vue: '3.5.44' }; + if (ssr) dependencies[framework] = frameworkVersion; + else + Object.assign(dependencies, { + vite: viteVersion, + [`@vitejs/plugin-${ui}`]: ui === 'react' ? '4.7.0' : '6.0.4', + }); + const next15 = framework === 'next' && frameworkVersion.startsWith('15.'); + const cli = + framework === 'next' + ? 'next/dist/bin/next' + : framework === 'nuxt' + ? 'nuxt/bin/nuxt.mjs' + : 'vite/bin/vite.js'; + return { + ui, + ssr, + dependencies, + versionPackage: ssr ? framework : 'vite', + requestedVersion: ssr ? frameworkVersion : viteVersion, + expectedSource: ui === 'vue' ? '/src/Card.vue' : '/src/main.jsx', + artifacts: [ + ...new Set([ + 'core', + 'manifest', + 'runtime', + 'relay', + 'overlay', + 'transform', + ui, + framework, + 'verify', + ]), + ], + cli, + bundler: framework === 'next' ? (next15 ? 'webpack' : 'turbopack') : 'vite', + devArgs: (port) => + ssr + ? [ + 'dev', + '--port', + String(port), + '--host' + (framework === 'next' ? 'name' : ''), + '127.0.0.1', + ] + : ['--port', String(port)], + buildArgs: ['build'], + productionArgs: (port) => + framework === 'next' + ? ['start', '--port', String(port), '--hostname', '127.0.0.1'] + : framework === 'nuxt' + ? [] + : ['preview', '--host', '127.0.0.1', '--port', String(port)], + productionEntry: framework === 'nuxt' ? '.output/server/index.mjs' : null, + }; +} diff --git a/scripts/verify-instance-replay.mjs b/scripts/verify-instance-replay.mjs index 10858de..f37df06 100644 --- a/scripts/verify-instance-replay.mjs +++ b/scripts/verify-instance-replay.mjs @@ -8,22 +8,37 @@ import net from 'node:net'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { execFileSync, spawn } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { writeReplayConsumer } from './fixtures/replay-consumer.mjs'; +import { replayProject } from './fixtures/replay-project.mjs'; +import { + startOwnedProcess, + stopOwnedProcess, + runOwnedBuild, +} from './fixtures/replay-process.mjs'; const { values } = parseArgs({ options: { framework: { type: 'string', default: 'react' }, 'package-manager': { type: 'string', default: 'npm' }, - 'vite-version': { type: 'string', default: '7.3.7' }, + 'vite-version': { type: 'string' }, 'report-dir': { type: 'string' }, + 'framework-version': { type: 'string' }, }, }); const framework = values.framework, manager = values['package-manager'], - viteVersion = values['vite-version']; -assert(['react', 'vue'].includes(framework), 'framework must be react or vue'); + viteVersion = values['vite-version'] ?? '7.3.7'; +const project = replayProject( + framework, + values['framework-version'], + viteVersion, +); +assert( + !(project.ssr && values['vite-version']), + 'vite-version is only supported for react/vue', +); assert( ['npm', 'pnpm'].includes(manager), 'package-manager must be npm or pnpm', @@ -43,32 +58,17 @@ const consumer = path.join(work, 'consumer'); fs.mkdirSync(consumer, { recursive: true }); const packs = path.join(work, 'packages'); fs.mkdirSync(packs); -const names = [ - 'core', - 'manifest', - 'runtime', - 'relay', - 'overlay', - 'transform', - framework, - 'verify', -]; -const dependencies = - framework === 'react' - ? { - react: '18.3.1', - 'react-dom': '18.3.1', - vite: viteVersion, - '@vitejs/plugin-react': '4.7.0', - } - : { vue: '3.5.44', vite: viteVersion, '@vitejs/plugin-vue': '6.0.4' }; -let server, browser; +const names = project.artifacts; +const dependencies = project.dependencies; +let server, browser, log; const evidence = { at: new Date().toISOString(), node: process.version, framework, packageManager: manager, - requestedVite: viteVersion, + requestedVite: project.ssr ? undefined : viteVersion, + requestedFrameworkVersion: project.ssr ? project.requestedVersion : undefined, + bundler: project.bundler, installMode: 'local-tarballs', localArtifactOverrides: manager === 'pnpm', work, @@ -77,6 +77,10 @@ const evidence = { }; const check = (name, details) => evidence.checks.push({ name, pass: true, ...details }); +async function stopServer() { + await stopOwnedProcess(server); + server = undefined; +} try { for (const name of names) { const artifact = path.join(root, 'dist/packages', `domscribe-${name}`); @@ -160,8 +164,9 @@ try { await new Promise((resolve) => server.close(resolve)); return port; } - async function until(fn) { - for (let i = 0; i < 100; i++) { + async function until(fn, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { try { const value = await fn(); if (value) return value; @@ -183,15 +188,21 @@ try { ); const installed = JSON.parse( fs.readFileSync( - path.join(consumer, 'node_modules/vite/package.json'), + path.join( + consumer, + 'node_modules', + project.versionPackage, + 'package.json', + ), 'utf8', ), ); - assert.equal(installed.version, viteVersion); - evidence.installedVite = installed.version; + assert.equal(installed.version, project.requestedVersion); + evidence.installedVite = project.ssr ? undefined : installed.version; + evidence.appFrameworkVersion = project.ssr ? installed.version : undefined; evidence.frameworkVersion = JSON.parse( fs.readFileSync( - path.join(consumer, 'node_modules', framework, 'package.json'), + path.join(consumer, 'node_modules', project.ui, 'package.json'), 'utf8', ), ).version; @@ -201,7 +212,7 @@ try { const runtimePath = fs.realpathSync( consumerRequire.resolve('@domscribe/runtime'), ); - for (const name of ['overlay', framework]) { + for (const name of new Set(['overlay', project.ui, framework])) { const packageRequire = createRequire( consumerRequire.resolve(`@domscribe/${name}`), ); @@ -211,14 +222,35 @@ try { `${name} must use the same local runtime`, ); } - const vite = path.join(consumer, 'node_modules/vite/bin/vite.js'); - const port = await freePort(), - log = fs.openSync(path.join(work, 'vite.log'), 'a'); - server = spawn(process.execPath, [vite, '--port', String(port)], { - cwd: consumer, - stdio: ['ignore', log, log], - }); - await until(async () => (await fetch(`http://127.0.0.1:${port}`)).ok); + const cli = path.join(consumer, 'node_modules', project.cli); + const port = await freePort(); + log = fs.openSync(path.join(work, 'server.log'), 'a'); + const serverEnv = { + ...process.env, + NEXT_TELEMETRY_DISABLED: '1', + NUXT_TELEMETRY_DISABLED: '1', + RAYON_NUM_THREADS: '2', + HOST: '127.0.0.1', + PORT: String(port), + NODE_OPTIONS: '--max-old-space-size=4096', + }; + function startServer(entry, args) { + return startOwnedProcess(process.execPath, [entry, ...args], { + cwd: consumer, + env: serverEnv, + stdio: ['ignore', log, log], + }); + } + server = startServer(cli, project.devArgs(port)); + await until( + async () => + ( + await fetch(`http://127.0.0.1:${port}`, { + signal: AbortSignal.timeout(1000), + }) + ).ok, + project.ssr ? 120000 : 10000, + ); browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'], @@ -227,7 +259,29 @@ try { let context = await browser.newContext(); let page = await context.newPage(); const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); + evidence.pageErrors = errors; + const consoleErrors = [], + networkErrors = []; + evidence.consoleErrors = consoleErrors; + evidence.networkErrors = networkErrors; + function observePage(page) { + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('requestfailed', (request) => { + if (request.failure()?.errorText !== 'net::ERR_ABORTED') + networkErrors.push({ + url: request.url(), + error: request.failure()?.errorText, + }); + }); + page.on('response', (response) => { + if (response.status() >= 400) + networkErrors.push({ url: response.url(), status: response.status() }); + }); + } + observePage(page); await page.goto(`http://127.0.0.1:${port}`); await page.waitForFunction(() => window.__runtime?.isReady()); const relayPort = await page.evaluate(() => window.__DOMSCRIBE_RELAY_PORT__); @@ -253,9 +307,7 @@ try { column: source.entry.start.column, }); assert( - source.entry.file.endsWith( - framework === 'vue' ? '/src/Card.vue' : '/src/main.jsx', - ), + source.entry.file.endsWith(project.expectedSource), 'Mapped source must belong to the selected framework fixture', ); evidence.mappedSource = source.entry.file; @@ -301,11 +353,11 @@ try { assert.equal(liveEditor.context.componentProps.customer.name, 'Lyra'); const editorState = liveEditor.context.componentState; assert.equal( - framework === 'vue' ? editorState.city : editorState.state_0, + project.ui === 'vue' ? editorState.city : editorState.state_0, 'Austin', ); assert.equal( - framework === 'vue' ? editorState.confirmed : editorState.state_1, + project.ui === 'vue' ? editorState.confirmed : editorState.state_1, true, ); check('exact editor instance exposes committed typed and checked state', { @@ -314,6 +366,11 @@ try { const save = page.getByRole('button', { name: 'Save', exact: true }); await record(save, { action: 'click' }); await save.click(); + assert.equal( + (await client.queryTargets({ instanceId: field.instanceId })).status, + 'stale', + ); + check('unmounted editor handle is stale after saving', {}); const trace = { version: 1, steps }; const annotation = await client.createAnnotation({ mode: 'element-click', @@ -343,7 +400,7 @@ try { await context.close(); context = await browser.newContext(); page = await context.newPage(); - page.on('pageerror', (error) => errors.push(error.message)); + observePage(page); await page.goto(`http://127.0.0.1:${port}`); await page.waitForFunction(() => window.__runtime?.isReady()); await until( @@ -405,24 +462,34 @@ try { assert.equal(stopped.completed, 0); check('ambiguous replay stops before any action', {}); assert.deepEqual(errors, []); + assert.deepEqual(consoleErrors, []); + assert.deepEqual(networkErrors, []); check('no application page errors', { errors }); await page.screenshot({ path: path.join(work, 'replayed.png') }); await context.close(); - const build = execFileSync(process.execPath, [vite, 'build'], { + await stopServer(); + await runOwnedBuild(process.execPath, [cli, ...project.buildArgs], { cwd: consumer, - encoding: 'utf8', + env: { ...serverEnv, NODE_ENV: 'production' }, + logPath: path.join(work, 'production-build.log'), }); - fs.writeFileSync(path.join(work, 'production-build.log'), build); - server.kill('SIGTERM'); - await new Promise((resolve) => server.once('exit', resolve)); - server = spawn( - process.execPath, - [vite, 'preview', '--host', '127.0.0.1', '--port', String(port)], - { cwd: consumer, stdio: ['ignore', log, log] }, + server = startServer( + project.productionEntry + ? path.join(consumer, project.productionEntry) + : cli, + project.productionArgs(port), + ); + await until( + async () => + ( + await fetch(`http://127.0.0.1:${port}`, { + signal: AbortSignal.timeout(1000), + }) + ).ok, + project.ssr ? 120000 : 10000, ); - await until(async () => (await fetch(`http://127.0.0.1:${port}`)).ok); const production = await browser.newPage(); - production.on('pageerror', (error) => errors.push(error.message)); + observePage(production); await production.goto(`http://127.0.0.1:${port}`); await production .getByRole('heading', { name: 'Customer operations' }) @@ -465,6 +532,8 @@ try { 'Seattle', ); assert.deepEqual(errors, []); + assert.deepEqual(consoleErrors, []); + assert.deepEqual(networkErrors, []); check( 'production UI still updates only the selected customer without page errors', {}, @@ -486,7 +555,8 @@ try { process.exitCode = 1; } finally { await browser?.close(); - server?.kill('SIGTERM'); + await stopServer(); + if (log !== undefined) fs.closeSync(log); const lock = path.join(consumer, '.domscribe/relay.lock'); if (fs.existsSync(lock)) { const relay = JSON.parse(fs.readFileSync(lock, 'utf8')); @@ -508,7 +578,7 @@ try { 'replayed.png', 'production-build.log', 'install.log', - 'vite.log', + 'server.log', ...(evidence.failureScreenshots ?? []), ]) { const from = path.join(work, name);