mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept
A ground-up hardening of live mode, driven by a production session in a nested-app monorepo that hit six distinct failure classes. Full design rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now has a mechanical fix and a regression test. Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot (keyed on dev-server configs, not monorepo brand markers), persists a manifest, and every live CLI re-anchors onto it at startup, so a helper run from the wrong directory can no longer fork session state. Context files are discovered upward to the git root. Render truth: variant_mounted / variant_mount_failed events give the journal per-variant mount state; failures reach the agent's poll queue, raise a persistent error card with Retry (no more localStorage wipe), and an attach probe names root/dev-server mismatches explicitly. The browser rehydrates from the server when localStorage is gone. Svelte: the scaffolder now parses with the app's own svelte 5 compiler. Control flow survives (an each collection crosses the contract as one structured prop), keyed each blocks hydrate synthetic keys, and anything a detached preview cannot support falls back to source-preview instead of shipping a wrong scaffold. Preview modules live in per-publish revision directories, defeating stale transform caches. Accept: CSS is reconciled, not appended. Matching selectors are replaced, params bake from params.json kinds, the compiler's unused-selector pass prunes superseded rules (pre-existing dead rules protected), a selector- loss postcondition refuses any write that would drop hand-written rules, and live-complete refuses to finish while live plumbing remains in source. Also: framework registry (live/frameworks/) with a crash-safe injection journal, session-store snapshot caching with read-only reads, protocol enum consolidation, steer Send button, honest DESIGN-panel empty states. Testing: new unit suites (roots, AST scaffolder, accept CSS, accept pipeline, framework conformance); e2e now fails on preview-tree 404s, proves computed-style mount for every variant, drives the Tune panel through baked params, and injects failures (broken mounts, republish, storage loss). New runtime fixtures: monorepo-nested-vite (repo root != app root) and vite8-sveltekit-stateful (each blocks + state). Nightly full-matrix cron. An independent adversarial review pass preceded this commit; its blocker and major findings are fixed and regression-tested. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
839dd10079
commit
17dabf4b7e
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
# Nightly full live-e2e matrix. The smoke groups already gate every PR; the
|
||||
# full sweep is too slow for that, so it runs once a day against main.
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -157,14 +161,16 @@ jobs:
|
||||
name: live-e2e smoke (${{ matrix.group }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch'
|
||||
if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- group: platform
|
||||
fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
|
||||
fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit
|
||||
- group: svelte
|
||||
fixtures: vite8-sveltekit-stateful
|
||||
- group: react
|
||||
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
|
||||
steps:
|
||||
@@ -225,14 +231,16 @@ jobs:
|
||||
name: live-e2e full (${{ matrix.group }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch'
|
||||
if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- group: platform
|
||||
fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
|
||||
fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router
|
||||
- group: svelte
|
||||
fixtures: vite8-sveltekit,vite8-sveltekit-stateful
|
||||
- group: react-a
|
||||
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
|
||||
- group: react-b
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [
|
||||
"tests/fixtures/**",
|
||||
"tests/framework-fixtures/**",
|
||||
"tests/detect-antipatterns.test.js"
|
||||
],
|
||||
"ignoreValues": [
|
||||
|
||||
@@ -159,6 +159,12 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de
|
||||
|
||||
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`.
|
||||
|
||||
Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`):
|
||||
|
||||
- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest.
|
||||
- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r<N>/`, bumped by the server on every done-reply), not by file watching.
|
||||
- **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free.
|
||||
|
||||
The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic.
|
||||
|
||||
**LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`, which calls Claude (default Haiku 4.5) via `@anthropic-ai/sdk`. Requires `ANTHROPIC_API_KEY` in env; the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL=claude-sonnet-4-6` if Haiku produces unreliable JSON. Caching is on — live.md is the cacheable prefix, and after the first call subsequent fixtures pay only the cache-read rate. Pass rate on a typical sweep is 18/19; the modal fixture's intrinsic state-loss flake is amplified by LLM latency and may need a re-run. **This path hits the API and costs money** — keep it out of CI unless you really want it there.
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"ai": "^7.0.14",
|
||||
"archiver": "^8.0.0",
|
||||
"playwright": "^1.59.1",
|
||||
"svelte": "^5",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -74,6 +75,16 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
|
||||
@@ -82,6 +93,12 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
|
||||
@@ -90,6 +107,8 @@
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"ai": ["ai@7.0.31", "", { "dependencies": { "@ai-sdk/gateway": "4.0.23", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
@@ -102,8 +121,12 @@
|
||||
|
||||
"archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
@@ -142,6 +165,8 @@
|
||||
|
||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
@@ -172,6 +197,8 @@
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
||||
@@ -202,6 +229,10 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
@@ -270,6 +301,8 @@
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
@@ -290,6 +323,10 @@
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -398,6 +435,8 @@
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="],
|
||||
|
||||
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
|
||||
@@ -434,6 +473,8 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Live v2: architecture plan
|
||||
|
||||
> **Status (2026-07-27): implemented.** P0 through P3 landed in one pass: roots manifest (`skill/scripts/live/roots.mjs`, every live CLI re-anchors via `enterLiveRoot`), mount-ack protocol with per-variant render truth and persistent error card, server-first rehydration, AST scaffolder (`live/svelte-ast.mjs`) with source-preview fallback, unified mechanical accept (`live/accept-css.mjs`, compiler-pruned; `live-complete` refuses dirty source), revisioned preview dirs bumped per publish, attach probe with named root-mismatch diagnosis, monorepo runtime fixture, and e2e failing on preview-tree 404s. One deliberate deviation from section 3.3: the preview tree stays under `node_modules/.impeccable-live` because SvelteKit restricts vite `server.fs.allow` to src/lib, routes, .svelte-kit, and node_modules (verified: `.impeccable/` under the app root 403s); staleness is defeated by per-publish revision directories instead of watcher reliance. P4 (registry, consolidation, control-flow fixtures, nightly matrix) in flight.
|
||||
|
||||
Driven by the 2026-07-25 Codex session in `~/code/agent-reviews` (session `019f9bf4-24a3-7661-afc5-bba7eb1e327f`), where a SvelteKit monorepo live session hit a chain of failures: wrong project root, silent 404 on variant modules, flattened `{#each}` blocks, stale module republish, unrecoverable browser state loss, and an accept that appended CSS instead of merging it. Every finding below was verified against current source; this is not a transcription of the Codex report.
|
||||
|
||||
## 1. Verified findings (Codex claim → root cause in code)
|
||||
|
||||
| # | Codex finding | Verified root cause |
|
||||
|---|---|---|
|
||||
| 1 | Wrong monorepo root | `findMonorepoRoot()` (`skill/scripts/context.mjs:262-303`) only recognizes "official" monorepos (workspaces field, turbo/pnpm/nx/lerna markers). A plain repo with a nested `website/` app falls into the non-monorepo branch and roots at cwd. `nearestTargetContextRoot` deliberately ignores `package.json` as a marker. Nothing ever correlates the dev server's root with `projectRoot`. |
|
||||
| 2 | Published mistaken for rendered | `arrivedVariants` is backfilled from `expectedVariants` on the agent's `--reply done` (`live/session-store.mjs:211-241`). On the component-preview path the browser never sends a mount checkpoint; success calls only `saveSession()` (`live-browser.js:5592`). There is no `mounted` / `mount_failed` anywhere in the protocol. |
|
||||
| 3 | Silent mount failures | Import catch is `console.error` + `return false` (`live-browser.js:5413-5419`). First-mount failure calls `abortSvelteComponentInjection` (`:5621-5651`), which clears localStorage, resets to PICKING, shows a 5s toast, and tells the server nothing. Variant-switch failure has no user feedback at all (`:4941-4950`). |
|
||||
| 4 | Svelte `{#each}` flattened | Prop extraction is pure regex (`live/svelte-component.mjs:18,44-88`). `{#each}` / `{/if}` block tokens become scalar string props (`prop0`, `prop5`); a 5-item loop scaffolds as one `<li>`. A second, independent slot-shift bug: `buildSvelteExpressionTextMap` (`live-browser.js:5830-5871`) zips source tokens against live text nodes by index, and block tokens consume slots. |
|
||||
| 5 | Brittle temp module paths | Modules written to `<projectRoot>/node_modules/.impeccable-live/` but imported from `location.origin` root-relative (`live-browser.js:5163-5165`). Two roots that must agree by convention. Manifest/CSS come from the helper's `/source`; the executable module from the dev server. Two transports for one artifact. |
|
||||
| 6 | Stale republish | Cache-bust is only a client-side `?t=Date.now()` on the leaf module (`live-browser.js:5377`). Vite's watcher ignores `node_modules`, so a rewrite of `vN.svelte` never invalidates the dev server's transform cache. Scaffold is write-once (`svelte-component.mjs:173`). No revision in the path; the runtime shim is memoized forever. |
|
||||
| 7 | Browser-local state is a single point of failure | Every restore path gates on localStorage first (`live-browser.js:8196-8246`). Server `activeSessions` only enrich an already-known local id. Mount failure deliberately wipes local state, manufacturing the orphaned-durable-session case. |
|
||||
| 8 | Parent context not discovered | Context root and project root are one variable. In the non-monorepo branch `repoRoot = absCwd` (`context.mjs:213`), so `website/` never looks one directory up for PRODUCT.md / DESIGN.md. The Codex session worked around it with symlinks that are still on disk. |
|
||||
| 9 | DESIGN panel disagrees with helper | Two causes: the panel is content-driven (needs frontmatter/sidecar, `live-browser.js:11037-11068`) while `hasDesign` is presence-driven; and the server snapshot of context is frozen at module load (`live-server.mjs:61-65`) while `live.mjs` re-resolves per boot and reuses a running server. |
|
||||
| 10 | Stop leaves `__runtime.js` | Sweeper skips non-directories and `__*` names (`svelte-component.mjs:732-742`). Confirmed still on disk in agent-reviews. Also unswept: accept receipts, session journals, deferred accepts in `os.tmpdir()`. |
|
||||
| 11 | CSS appended, not merged | `appendCssToSvelteStyle` (`svelte-component.mjs:278-296`) splices the variant CSS before the last `</style>`. No reconciliation exists. Worse: `mergeOriginalTopLevelAttrs` (`:646-681`) copies the original root's classes onto the new markup, guaranteeing stale rules keep matching. |
|
||||
| 12 | Incomplete param baking | `sanitizeAcceptedSvelteCss` early-returns unless CSS contains `data-impeccable-variant` (`svelte-component.mjs:311-312`), which authoring rules forbid on this path, so the entire steps/toggle pruning pipeline is unreachable dead code. Only `range` vars get substituted; `toggle` bakes the raw JS boolean (`true`) into CSS. |
|
||||
| 13 | Formatting destroyed | `line.trimStart()` + one flat indent for every markup line (`svelte-component.mjs:544-547`); every CSS line reindented to 2 spaces (`:280`). No formatter anywhere. The Branch-H (HTML/JSX) path preserves relative indent; the Svelte path regressed against its own sibling. |
|
||||
| 14 | Preview cascade ≠ accepted cascade | Svelte scoping is compile-time per file; a detached preview component inherits none of the route's scoped CSS, so variants reimplement everything. The runtime then injects the variant CSS a second time, re-prefixed and un-hashed (`live-browser.js:5220-5280`), with different specificity than the compiled copy. |
|
||||
| 18 | Steer affordance | No Send button; Enter-only (`live-browser.js:9558-9712`). Good loading state, no queue-position feedback, 120s timeout blames the wrong component. The element-level Go bar has a visible submit button; the steer bar doesn't. |
|
||||
|
||||
Findings 15-17 (hook anti-pattern gaps, copy quality, visual-collision detection) are design-hook scope, not live scope; tracked separately.
|
||||
|
||||
Structural facts that explain why these all shipped:
|
||||
|
||||
- The 826-line `svelte-component.mjs`, which owns scaffolding, CSS append, and param baking, has **zero direct unit tests**. The only runtime Svelte fixture (`vite8-sveltekit`) has no props, no `{#if}`, no `{#each}`, no `<script>`.
|
||||
- No runtime fixture has repo root ≠ app root. `nextjs-turborepo` is static-only and its one wrap case asserts `element_not_found`.
|
||||
- The e2e console check allowlists 404s, the exact signal of a missing variant module.
|
||||
- Accept has two implementations. The correct merge semantics (carbonize's five steps) exist only as prose in `live.md`, and apply only to the HTML/JSX path. The Svelte path hard-codes `carbonize: false` = "nothing to do."
|
||||
- `projectRoot` is an ambient parameter (child-process cwd), re-derived independently in at least 12 scripts, never persisted, never validated.
|
||||
|
||||
## 2. Diagnosis: five structural problems
|
||||
|
||||
1. **Root identity is conflated and ambient.** Four distinct concepts travel as one cwd: repo root (git), app root (what the dev server serves), context root (where PRODUCT.md / DESIGN.md live), session root (where `.impeccable/live` state goes). Nothing validates the choice against reality.
|
||||
2. **The protocol has no truth about rendering.** The server's state machine ends at "agent said done." Compile, import, and mount happen on the other side of a network boundary with no acknowledgement channel, so every failure past publish is invisible to the agent and the journal.
|
||||
3. **The Svelte path is a parallel universe.** Its own scaffolding (regex, not AST), its own serving path (dev server + helper, two transports), its own CSS semantics (detached compile scope, double injection), and its own accept (append, no carbonize). Every one of this session's worst bugs lived in that universe.
|
||||
4. **Acceptance enforces the wrong things.** Mechanical code handles locking, receipts, and markers well, but the actual quality contract (merge CSS, prune dead branches, bake params, format) is prose for one path and absent for the other. `live-complete.mjs` acknowledges without reading the file.
|
||||
5. **Tests assert bookkeeping, not reality.** DOM mount is verified once, for one variant, in fake mode; component-preview "arrival" is the browser's own counter; accepted-source checks are marker absence plus one coarse regex. The failure modes that burned this session (404, stale module, nested root) have no test.
|
||||
|
||||
## 3. Target architecture
|
||||
|
||||
### 3.1 Root manifest: resolve once, pass explicitly, validate at attach
|
||||
|
||||
Introduce a single `resolveRoots(target)` in one module, producing a persisted manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"appRoot": "/repo/website", // nearest dir above target with package.json + dev-server config (vite/svelte/next/astro/nuxt config file)
|
||||
"repoRoot": "/repo", // git root
|
||||
"contextRoot": "/repo", // nearest dir from appRoot up to repoRoot holding PRODUCT.md/DESIGN.md
|
||||
"sessionRoot": "/repo/website/.impeccable/live",
|
||||
"resolvedFrom": "target:website/src/routes/+page.svelte"
|
||||
}
|
||||
```
|
||||
|
||||
- App-root detection keys on **dev-server config presence**, not monorepo brand markers. A nested `website/` with `vite.config.js` wins over the repo root regardless of workspaces fields. The "official monorepo" logic becomes one input, not the gatekeeper.
|
||||
- Context discovery walks **up from appRoot to repoRoot** (git boundary), checking each level. This makes the agent-reviews symlink workaround unnecessary and removes the `repoRoot = absCwd` bug.
|
||||
- The manifest is written into `server.json` at boot. Every helper script (`live-wrap`, `live-accept`, `live-poll`, `live-resume`, `live-status`, adapters) takes `--root` or reads the manifest; **none re-derives roots from cwd**. A helper invoked from a different cwd finds the manifest via upward search and uses it, instead of silently forking a second empty project (which also fixes the two-servers-two-ports failure in `readLiveServerInfo`).
|
||||
- **Attach-time validation**: on handshake, the browser fetches a probe module the helper wrote under the assumed app root (`<appRoot>/.impeccable/live/preview/__probe.js`) through the **dev server origin**. If it 404s, the root assumption is wrong or the dev server doesn't serve that tree; the session fails at boot with a named error (`preview_unreachable`, including both the assumed root and the failed URL) instead of failing silently at first variant. This one check would have caught the entire agent-reviews root fiasco in second one.
|
||||
|
||||
### 3.2 Delivery state machine with mount acknowledgements
|
||||
|
||||
Make the variant lifecycle explicit, per variant: `published → fetched → compiled → mounted | failed`.
|
||||
|
||||
- Browser emits `variant_mounted {variant, revision}` after a successful mount and `variant_mount_failed {variant, url, error}` from every import/mount catch, including variant switches. These are first-class events (like `agent_error`), not checkpoints, so they reach the agent's poll queue.
|
||||
- Server phase reaches `variants_ready` only when at least one mount ack arrives; a new `variants_published` phase covers the gap. `arrivedVariants` is only ever incremented by mount acks; delete the `expectedVariants` backfill.
|
||||
- UI: mount failure renders a **persistent error card** in the bar (failed URL, error, Retry button) and keeps the session alive. `abortSvelteComponentInjection`'s clear-everything behavior is deleted; state reset happens only on explicit user discard/exit.
|
||||
- `live-resume.mjs` / `live-status.mjs` report the per-variant mount state, so the agent can distinguish "user is comparing" from "nothing ever rendered."
|
||||
|
||||
### 3.3 Variant serving: one transport, revisioned paths, watched directory
|
||||
|
||||
- Move the preview tree from `node_modules/.impeccable-live/` to `<appRoot>/.impeccable/live/preview/<sessionId>/r<revision>/`. Under the app root, Vite (and SvelteKit/Astro/Nuxt/TanStack, all Vite-family) serves and **watches** it, so republish invalidation is native HMR instead of a `?t=` fig leaf.
|
||||
- **Revision in the path**, bumped on every publish. Republish = new directory; the old one is deleted. Staleness becomes structurally impossible; no query-string games, no memoized runtime shim problem (the shim lives inside the revisioned tree).
|
||||
- The browser imports the module and reads params/manifest **through the same dev-server origin**; the helper `/source` route stays for source files only. One transport, one cache story. Respect the dev server's `base` by deriving the URL from the injected script's own URL rather than `location.origin`.
|
||||
- Sweep the whole preview tree on stop and on boot (heals crashes and `kill -9`). Receipts, journals, and deferred accepts move under the same `sessionRoot` with a retention sweep at boot; the `os.tmpdir()` deferred-accepts file is retired.
|
||||
|
||||
### 3.4 Svelte scaffolding on the real AST
|
||||
|
||||
The project being edited has `svelte` installed; the scaffolder can `import('svelte/compiler')` from the **app's** node_modules (resolved from appRoot).
|
||||
|
||||
- `parse()` gives a real template AST. Control-flow blocks (`{#each}`, `{#if}`, `{#await}`, `{#snippet}`) are preserved as blocks. Collections cross the prop contract as **structured values** (the `{#each}` source expression becomes one array prop hydrated from the live DOM), never as flattened scalar text.
|
||||
- The live-DOM-to-prop text mapping stops zipping by index; the AST tells us which text nodes are expressions and which are static.
|
||||
- Round-trip invariant, enforced by test: `restore(scaffold(source)) === source` for every fixture component. This is the property the regex version silently broke.
|
||||
- If `svelte/compiler` can't be resolved or the parse fails, fall back to **source-preview mode** (the wrapper path every other framework uses) rather than shipping a wrong scaffold. Degraded-but-correct beats clever-but-broken.
|
||||
- Seed each variant stub with the source component's `<style>` rules that matched the selected element, so variants start from the real cascade instead of reimplementing it blind, and delete the runtime's second re-prefixed CSS injection (`applySvelteComponentVariantStyle`): the compiled component already carries its scoped styles.
|
||||
|
||||
### 3.5 One accept pipeline, mechanical reconcile, enforced postconditions
|
||||
|
||||
Collapse Branch S and Branch H into one flow: **splice markup → reconcile CSS → bake params → format → verify**.
|
||||
|
||||
- **CSS reconcile, not append.** Parse both the component's existing style block and the variant CSS (vendor `css-tree` or an equivalent small parser into `skill/scripts/live/`). Replace rules whose selectors match, append genuinely new rules. Then use the framework's own compiler as the dead-rule oracle: compile the accepted `.svelte` file and delete every rule the compiler reports as an unused selector. That deterministically removes the stale divider rules this session shipped.
|
||||
- **Param baking driven by `params.json`**, not regex sniffing: `range` → substitute the literal (or set the var's new default), `steps` → keep the chosen `[data-p-*]` branch, rewrite to a semantic selector, delete siblings; `toggle` → normalize to `0`/`1`, same branch logic. Scrub every `data-p-*` / `data-impeccable-*` attribute from promoted markup. All pure functions, all unit-tested.
|
||||
- **Formatting**: preserve relative indentation on splice (port Branch H's `deindentContent` approach), then run the project's own formatter if configured (detect prettier/biome config; run on the touched file only; skip silently if absent).
|
||||
- **Postcondition gate**: a `verifyAcceptedSource(file)` scanner (no markers, no `data-p-*`, no `var(--p-`, no duplicate selectors vs. pre-accept, file parses in its framework). `live-complete.mjs` runs it and **refuses to complete while dirty**, returning findings for the agent to fix. The carbonize prose contract becomes enforced, and the same scanner runs in every e2e fixture.
|
||||
- Add the generated-file refusal to the Svelte path (it currently only exists on Branch H).
|
||||
|
||||
### 3.6 Server-first browser rehydration
|
||||
|
||||
- On SSE `connected`, if localStorage has no session but the server reports active sessions for this page URL, **rehydrate from the server snapshot**: session id, phase, variant count, mount states, preview manifest, param values. localStorage becomes a cache for browser-only extras (scroll, picked-anchor viewport hint), not the source of truth.
|
||||
- The snapshot already carries almost everything (`summarizeActiveSessionForClient`); add the picked-element anchor descriptor to the `generate` event payload so it is journaled and replayable.
|
||||
- Result: closed tab, cleared storage, different browser profile, or the mount-failure wipe all recover to the same comparison. `live-resume.mjs`'s "tell the user to re-click Go" era ends.
|
||||
|
||||
### 3.7 Framework support as a registry with a conformance contract
|
||||
|
||||
Today framework knowledge is smeared across `live-inject.mjs`, two adapters, `live-wrap.mjs`, and browser special cases. Define a registry (`skill/scripts/live/frameworks/<name>.mjs`) where each entry declares:
|
||||
|
||||
```
|
||||
detect(appRoot) → confidence
|
||||
inject / remove → how live.js reaches the page (crash-safe: journal what was written, heal on boot)
|
||||
previewStrategy → 'source-wrapper' | 'component' (+ scaffolder)
|
||||
cssAuthoring → mode + styleTag
|
||||
acceptStrategy → shared pipeline options
|
||||
conformance → fixture name(s)
|
||||
```
|
||||
|
||||
"Supported framework" then has an operational definition: **its fixture passes the shared conformance scenario battery** (pick → Go → all variants mount-verified → tune params → accept → postcondition scan → resume-after-reload → mount-failure recovery). Adding a framework = adding a registry entry + a fixture; the battery is the same for all. This is what makes each framework rock solid instead of anecdotally working.
|
||||
|
||||
## 4. Testing plan
|
||||
|
||||
**Unit (default suite, cheap):**
|
||||
1. `svelte-component` scaffolder: round-trip property tests (`restore(scaffold(x)) === x`) over a corpus including `{#each}`, `{#if}/{:else}`, `{#await}`, `{@const}`, snippets, `<script module>`, expression-bearing attributes.
|
||||
2. Accept pipeline: CSS reconciler (replace/append/delete cases, `@media` nesting), param baking per kind (including `calc()` in defaults and boolean normalization), postcondition scanner, indentation preservation on tab- and space-indented files.
|
||||
3. Root manifest: table-driven cases for plain nested app, workspaces monorepo, turbo, context above app root, target vs cwd disagreement, two-servers prevention.
|
||||
|
||||
**Runtime e2e (hardened):**
|
||||
4. **Monorepo fixture** (top priority): repo root with its own package.json, app in `apps/web` or `website/`, PRODUCT.md/DESIGN.md at repo root, dev server booted from the app dir. Asserts the manifest roots, probe validation, preview URLs, and accept landing in the right tree. Nothing today exercises root ≠ app root against a live server.
|
||||
5. **Promote `vite8-sveltekit-stateful` to runtime and add `{#each}`**; assert expression survival (`assertSourceContains: ["{#each", "{expenses[0].name}"]`). Add the same expression-survival assertion to `vite8-react-mapped-list` (`{item.title}`), copying the `vite8-react-tsx-repeated-aside` pattern; today the suite green-lights baking literals into mapped lists.
|
||||
6. **Mount proof for every variant**: per-variant computed-style markers in the fake agent, asserted for v1/v2/v3, on both preview paths, via DOM (never `debugState`).
|
||||
7. **Fail on 404s**: remove `Failed to load resource ... 404` from the console allowlist; explicitly assert zero requests to the preview tree returned 404.
|
||||
8. **Failure-injection scenarios** as first-class fixture options: delete `v2.svelte` before cycling (assert persistent error card + Retry + session survives), republish a corrected module (assert new revision mounts), kill localStorage mid-session (assert server rehydration), stop dev server before accept.
|
||||
9. **Params end-to-end**: the harness clicks Tune, changes a range + a steps value, accepts, and asserts the baked output (today Tune is never clicked, so baking is never exercised).
|
||||
10. **Accepted-source quality in every fixture**: run the shared postcondition scanner + `prettier --check` where the fixture has a config, instead of one coarse regex.
|
||||
|
||||
**Fake agent realism:**
|
||||
11. Variants must include nested markup (the current single-flat-element output structurally cannot catch container bugs), a mapped list on list fixtures, and distinct per-variant markers.
|
||||
|
||||
**CI cadence:**
|
||||
12. PR smoke set grows to include the monorepo fixture and the stateful Svelte fixture, and runs the failure scenarios (drop `SCENARIOS: core` gating for them). Full 20-fixture matrix moves from manual `workflow_dispatch` to a nightly cron with an issue filed on failure. Add a build check that fixture names in `ci.yml` match discovered runtime fixtures so the matrix can't silently drift.
|
||||
|
||||
## 5. Complexity and overhead reductions
|
||||
|
||||
- **Split `live-browser.js`** (11.7k lines, one file) into ES modules bundled at build time (the build system and `browser-script-parts.mjs` already exist). Unit-test the state machine and rehydration logic in jsdom; today the browser runtime is only testable end-to-end.
|
||||
- **One protocol module**: event types, phases, checkpoint reasons, and agent_phase values as shared enums imported by the validator, the browser build, and docs. Delete the ~9 agent_phase values nothing emits, the duplicate cycling counters, and two of the three revision counters.
|
||||
- **One root resolver, one glob matcher** (currently three glob implementations with "keep in sync" comments).
|
||||
- **Session store**: cache snapshots in memory keyed by journal mtime; stop replaying the full journal on every append/read, and stop writing snapshots as a side effect of reads (`live-status` currently mutates state).
|
||||
- **Crash-safe adapters**: injection journals what it wrote; boot heals leftovers (fixes the SIGKILL orphan and the `stop`-from-wrong-root orphan). `stop` sweeps the entire preview tree, receipts, and completed session journals.
|
||||
- **Steer bar**: add the visible Send button (the Go bar already has one), queue-position feedback when a generate holds the lease, and reword the timeout message.
|
||||
- **DESIGN panel**: serve context lazily (resolve on request, not at server module load) so a server outliving `impeccable document` stops lying; when DESIGN.md exists but has no parseable system, say that ("DESIGN.md found, no structured tokens") instead of "No design system data available."
|
||||
|
||||
## 6. Phasing
|
||||
|
||||
| Phase | Scope | Size |
|
||||
|---|---|---|
|
||||
| **P0: stop the bleeding** | Sweep `__runtime.js` + preview tree on stop/boot; persistent mount-error card + first-class `variant_mount_failed` event; delete the localStorage wipe on mount failure; context walk-up to git root; e2e: fail on 404s + expression-survival assertions (findings 3, 8, 10, partial 2) | days |
|
||||
| **P1: roots** | Root manifest, explicit `--root` everywhere, attach-time probe validation, monorepo runtime fixture (findings 1, 5, 8) | ~1 wk |
|
||||
| **P2: delivery truth** | Full mount-ack state machine, `arrivedVariants` from acks only, server-first rehydration, resume surfacing mount state, failure-injection e2e scenarios (findings 2, 3, 7) | ~1 wk |
|
||||
| **P3: Svelte + accept** | AST scaffolder with source-preview fallback, revisioned preview tree under appRoot, single-transport serving, unified accept pipeline (reconcile + bake + format + postcondition gate), stateful Svelte fixture + params e2e (findings 4, 6, 11-14) | 2-3 wk |
|
||||
| **P4: consolidation** | Framework registry + conformance battery, browser-runtime module split, protocol enum pruning, session-store caching, nightly full matrix (structural) | ongoing |
|
||||
|
||||
P0-P2 are independent of P3 and directly remove the four failure classes Codex ranked most costly (root detection, mount acks, Svelte scaffolding, accept merging); Svelte scaffolding and accept land in P3 because they need the parser and reconciler foundations.
|
||||
@@ -93,6 +93,7 @@
|
||||
"ai": "^7.0.14",
|
||||
"archiver": "^8.0.0",
|
||||
"playwright": "^1.59.1",
|
||||
"svelte": "^5",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export const SUITES = {
|
||||
runner: 'node',
|
||||
files: [
|
||||
'tests/live-accept.test.mjs',
|
||||
'tests/live-accept-css.test.mjs',
|
||||
'tests/live-accept-scrub.test.mjs',
|
||||
'tests/live-browser-dom.test.mjs',
|
||||
'tests/live-browser-script-parts.test.mjs',
|
||||
@@ -140,6 +141,7 @@ export const SUITES = {
|
||||
'tests/live-e2e-steer-agent.test.mjs',
|
||||
'tests/live-e2e/agent-insert.test.mjs',
|
||||
'tests/live-event-validation.test.mjs',
|
||||
'tests/live-frameworks.test.mjs',
|
||||
'tests/live-generation-preflight.test.mjs',
|
||||
'tests/live-inject.test.mjs',
|
||||
'tests/live-insert.test.mjs',
|
||||
@@ -150,10 +152,13 @@ export const SUITES = {
|
||||
'tests/live-poll-stream.test.mjs',
|
||||
'tests/live-recovery-commands.test.mjs',
|
||||
'tests/live-reference.test.mjs',
|
||||
'tests/live-roots.test.mjs',
|
||||
'tests/live-server.test.mjs',
|
||||
'tests/live-session-store.test.mjs',
|
||||
'tests/live-source-lock.test.mjs',
|
||||
'tests/live-source-search.test.mjs',
|
||||
'tests/live-svelte-ast.test.mjs',
|
||||
'tests/live-svelte-component-accept.test.mjs',
|
||||
'tests/live-tanstack-adapter.test.mjs',
|
||||
'tests/live-target-context.test.mjs',
|
||||
'tests/live-wrap.test.mjs',
|
||||
@@ -171,7 +176,8 @@ export const SUITES = {
|
||||
/^skill\/scripts\/(detect-csp|live-inject|live-wrap)\.mjs$/,
|
||||
/^skill\/scripts\/lib\/is-generated\.mjs$/,
|
||||
/^skill\/scripts\/lib\/template-extensions\.mjs$/,
|
||||
/^skill\/scripts\/live\/(source-search|sveltekit-adapter)\.mjs$/,
|
||||
/^skill\/scripts\/live\/(source-search|sveltekit-adapter|tanstack-adapter)\.mjs$/,
|
||||
/^skill\/scripts\/live\/frameworks\//,
|
||||
],
|
||||
commands: [
|
||||
{
|
||||
|
||||
+10
-5
@@ -12,7 +12,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node {{scripts_path}}/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node {{scripts_path}}/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`. The boot resolves the app root from dev-server config files (vite/svelte/next/astro/nuxt configs), not from the cwd alone, and persists the decision in `.impeccable/live/roots.json`; every live helper re-anchors to that manifest at startup, so a helper run from the wrong directory lands on the same roots instead of silently forking a second empty project. PRODUCT.md / DESIGN.md are discovered upward from the app root to the git root, so a nested app inherits repo-level context without symlinks. Because helpers chdir onto the app root, every relative path you pass on a helper command line (`--file`, `--target` excepted at boot) resolves against the app root, not against wherever the shell happened to be; that is the documented contract for `--file` and now holds regardless of your cwd.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
|
||||
node {{scripts_path}}/live.mjs
|
||||
```
|
||||
|
||||
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md, DESIGN.md, and any surface brief already loaded by Setup in mind for variant generation: **DESIGN.md wins on visual decisions; PRODUCT.md wins on durable product and voice decisions; the surface brief wins on this surface's strategy.** When DESIGN.md is missing, identity is **not** absent; extract it from CSS variables, computed styles, and sibling components on the page (see Step 4 Phase A). Identity preservation is the default; departure requires the user's explicit redesign/replacement intent.
|
||||
Output JSON: `{ ok, serverPort, serverToken, pageFiles, roots, hasProduct, product, productPath, hasDesign, design, designPath }`. `roots` is the resolved root manifest (`appRoot`, `repoRoot`, `contextRoot`, `sessionRoot`); `projectRoot` mirrors `roots.appRoot`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md, DESIGN.md, and any surface brief already loaded by Setup in mind for variant generation: **DESIGN.md wins on visual decisions; PRODUCT.md wins on durable product and voice decisions; the surface brief wins on this surface's strategy.** When DESIGN.md is missing, identity is **not** absent; extract it from CSS variables, computed styles, and sibling components on the page (see Step 4 Phase A). Identity preservation is the default; departure requires the user's explicit redesign/replacement intent.
|
||||
|
||||
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
|
||||
|
||||
@@ -61,10 +61,13 @@ LOOP:
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"manual_edit_apply" → Handle Manual Edit Apply; reply done|partial|error; LOOP
|
||||
"variant_mount_failed" → Fix the variant files; reply done --file <path>; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
`variant_mount_failed` means the browser could not render what you published (the event carries `variant`, the module `url`, and the `error`). The user is looking at a persistent error card, not at variants. Fix the variant files, then reply `--reply EVENT_ID done --file <manifest or source path>`; the browser retries the injection on its own once that reply lands.
|
||||
|
||||
**Stream mode (experimental, not for Cursor):**
|
||||
|
||||
```
|
||||
@@ -162,7 +165,9 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. The scaffold is AST-based: control-flow blocks (`{#each}`, `{#if}`) survive intact, a free each-collection crosses the contract as ONE structured prop (kind `collection`), and expressions bound by the loop stay verbatim in the stub. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`, keeping the stub's control flow and `propContract` prop names; never flatten a loop into literal items. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` merges the accepted component back into `sourceFile` mechanically: markup restored to route expressions, CSS reconciled into the existing `<style>` block (matching selectors replaced, superseded rules removed via the compiler's unused-selector pass), params baked from `params.json`, indentation preserved. Nothing is appended twice; you have no post-accept cleanup on this path.
|
||||
|
||||
When the selected markup contains constructs a detached preview cannot support (component tags, `bind:`/`use:` directives, await blocks, inline scripts, spread attributes), wrap returns the normal source-preview wrapper instead, with `previewFallback: { from: "svelte-component", reason }`. Just follow the returned wrapper shape; the fallback trades HMR state resets for correctness.
|
||||
|
||||
|
||||
**Params on component-preview paths go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, and both Svelte/Vue previews mount without an HTML variant wrapper. Declare params in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
@@ -493,7 +498,7 @@ Do these five steps synchronously before the next poll. The source lock, generat
|
||||
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. Poll again only after that verification.
|
||||
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. Poll again only after that verification. The command is a gate, not a formality: it scans the session's source file and refuses with `error: "source_dirty"` plus a findings list while any live-mode leftover remains (markers, `data-p-*` attributes, unbaked `var(--p-...)`). Fix the findings and rerun; `--force` exists only for false positives.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
@@ -607,7 +612,7 @@ Schema:
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
**Framework adapters (auto-detected at inject time).** Every inject records what it wrote in `.impeccable/live/inject-journal.json`; the next inject or remove heals any artifacts a crash or wrong-directory stop left behind, so leftover adapter files self-clean instead of accumulating. SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
inlineSvelteComponentAccept,
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
|
||||
@@ -946,6 +947,7 @@ function argVal(args, flag) {
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
|
||||
enterLiveRoot();
|
||||
acceptCli();
|
||||
}
|
||||
|
||||
|
||||
+751
-96
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,12 @@
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
import { verifyAcceptedFile } from './live/accept-verify.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
@@ -15,6 +19,7 @@ function parseArgs(argv) {
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--force') out.force = true;
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
@@ -23,10 +28,36 @@ function parseArgs(argv) {
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE] [--force]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.\nCompletion is refused while the session's source file still carries live-mode leftovers\n(markers, data-p-* attributes, unbaked --p-* vars); fix the file or pass --force.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
// The carbonize contract used to be prose; this makes it mechanical. A
|
||||
// "complete" while the source still carries live plumbing is how markers
|
||||
// and dead param branches accumulated across sessions.
|
||||
if (args.status === 'complete' && !args.force) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
const sourceFile = snapshot?.sourceFile;
|
||||
const absSource = sourceFile ? path.resolve(process.cwd(), sourceFile) : null;
|
||||
const relSource = absSource ? path.relative(process.cwd(), absSource) : null;
|
||||
const insideProject = relSource !== null && relSource !== '' && !relSource.startsWith('..') && !path.isAbsolute(relSource);
|
||||
if (insideProject && !relSource.startsWith('node_modules' + path.sep) && !relSource.startsWith('node_modules/')) {
|
||||
const verify = verifyAcceptedFile(fs, absSource);
|
||||
if (!verify.clean) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'source_dirty',
|
||||
id: args.id,
|
||||
file: sourceFile,
|
||||
findings: verify.findings,
|
||||
hint: 'The accepted source still carries live-mode leftovers. Finish the carbonize cleanup (bake params, remove markers and data-p-* attributes), then run live-complete again. Use --force only if a finding is a false positive.',
|
||||
}, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
@@ -71,5 +102,6 @@ async function completeThroughServer(info, args) {
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
enterLiveRoot();
|
||||
completeCli();
|
||||
}
|
||||
|
||||
+137
-412
@@ -7,6 +7,11 @@
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Framework knowledge lives in `live/frameworks/` — detection order, adapters,
|
||||
* the generic tag strategy, and the per-extension authoring traits live-wrap
|
||||
* reads. This file is the CLI around it: resolve config, resolve the
|
||||
* framework, heal orphaned artifacts, apply or remove, record the journal.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT [--token TOKEN] # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
@@ -23,22 +28,36 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
||||
import {
|
||||
applySvelteKitLiveAdapter,
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
describeInjectArtifacts,
|
||||
frameworkIgnorePatterns,
|
||||
resolveFramework,
|
||||
resolveSourceTraits,
|
||||
} from './live/frameworks/index.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
clearInjectJournal,
|
||||
healInjectJournal,
|
||||
recordInjection,
|
||||
} from './live/frameworks/journal.mjs';
|
||||
import {
|
||||
buildTagBlock,
|
||||
insertTag,
|
||||
patchCspMeta,
|
||||
removeTag,
|
||||
revertCspMeta,
|
||||
} from './live/frameworks/tag-strategy.mjs';
|
||||
import { buildLiveScriptSrc } from './live/frameworks/script-src.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
|
||||
const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
|
||||
// Resolved lazily so the enterLiveRoot() chdir in the CLI guard below takes
|
||||
// effect first; module scope runs before the guard.
|
||||
let CONFIG_PATH_CACHED = null;
|
||||
function CONFIG_PATH_GET() {
|
||||
if (!CONFIG_PATH_CACHED) {
|
||||
CONFIG_PATH_CACHED = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
}
|
||||
return CONFIG_PATH_CACHED;
|
||||
}
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
@@ -47,6 +66,9 @@ export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/roots.json',
|
||||
'.impeccable/live/app-root.json',
|
||||
'.impeccable/live/inject-journal.json',
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
@@ -102,60 +124,61 @@ Output (JSON):
|
||||
}
|
||||
|
||||
if (args.includes('--check')) {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
|
||||
// Deliberately read-only: --check runs from status paths and must never
|
||||
// mutate the tree. Journal reconciliation happens on the inject run.
|
||||
if (!fs.existsSync(CONFIG_PATH_GET())) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
|
||||
process.exit(0);
|
||||
}
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
validateConfig(cfg);
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
|
||||
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load config
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
|
||||
if (!fs.existsSync(CONFIG_PATH_GET())) {
|
||||
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
const cwd = process.cwd();
|
||||
const resolvedFiles = resolveFiles(cwd, config);
|
||||
const resolved = resolveFramework(cwd, config);
|
||||
const isAdapter = resolved?.framework.inject.kind === 'adapter';
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
if (isAdapter) {
|
||||
const adapterResult = resolved.framework.inject.remove({ cwd, config, project: resolved.project });
|
||||
const ok = !(adapterResult && adapterResult.error);
|
||||
// Anything the adapter could not reach (its detection may have shifted
|
||||
// since the session started) is still on the journal.
|
||||
const { healed } = healInjectJournal(cwd);
|
||||
clearInjectJournal(cwd);
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
adapter: resolved.framework.name,
|
||||
results: [adapterResult],
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
const absFile = path.resolve(cwd, relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
const detagged = removeTag(content, config.commentSyntax);
|
||||
@@ -168,7 +191,9 @@ Output (JSON):
|
||||
cspReverted: updated !== detagged,
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, results }));
|
||||
const { healed } = healInjectJournal(cwd);
|
||||
clearInjectJournal(cwd);
|
||||
console.log(JSON.stringify({ ok: true, results, healed: healed.length ? healed : undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,47 +208,55 @@ Output (JSON):
|
||||
// /live.js handler authorizes the browser fetch. `live.mjs` always passes it.
|
||||
const tokenIdx = args.indexOf('--token');
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, token, config });
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
// Reconcile before writing anything. Artifacts this run is about to own are
|
||||
// kept (so a repeat inject stays byte-idempotent); artifacts left behind by
|
||||
// a session that never got to stop are healed.
|
||||
const plannedArtifacts = describeInjectArtifacts(resolved, { cwd, files: resolvedFiles });
|
||||
const { healed } = healInjectJournal(cwd, { keep: plannedArtifacts.map((a) => a.path) });
|
||||
|
||||
const gitIgnore = ensureLiveGitIgnores(cwd, frameworkIgnorePatterns(resolved));
|
||||
// In a nested-app repo the roots pointer lives at the REPO root, outside the
|
||||
// reach of the appRoot-relative ignore block above; give that directory its
|
||||
// own local excludes so the pointer (absolute host paths) never gets staged.
|
||||
try {
|
||||
const rootsManifest = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'roots.json'), 'utf-8'));
|
||||
if (rootsManifest?.repoRoot && path.resolve(rootsManifest.repoRoot) !== path.resolve(cwd)) {
|
||||
ensureLiveGitIgnores(rootsManifest.repoRoot);
|
||||
}
|
||||
} catch { /* no manifest: single-root project */ }
|
||||
|
||||
if (isAdapter) {
|
||||
const adapterResult = resolved.framework.inject.apply({
|
||||
cwd,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
token,
|
||||
config,
|
||||
project: resolved.project,
|
||||
});
|
||||
const ok = !(adapterResult && adapterResult.error);
|
||||
if (ok) recordInjection(cwd, { framework: resolved.framework.name, port, artifacts: plannedArtifacts });
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
port,
|
||||
adapter: resolved.framework.name,
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'nuxt',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
const absFile = path.resolve(cwd, relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
|
||||
const withTag = insertTag(withoutOld, config, port, relFile, token);
|
||||
// Per-file, not per-project: a Vite app can hold an .astro partial, and a
|
||||
// framework project's entry template is often plain HTML.
|
||||
const scriptAttrs = resolveSourceTraits(relFile).injectScriptAttrs;
|
||||
const withTag = insertTag(withoutOld, config, port, token, scriptAttrs);
|
||||
if (withTag === withoutOld) {
|
||||
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
|
||||
}
|
||||
@@ -236,7 +269,19 @@ Output (JSON):
|
||||
};
|
||||
});
|
||||
const anyInserted = results.some((r) => r.inserted);
|
||||
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
|
||||
const writtenFiles = new Set(results.filter((r) => r.inserted).map((r) => r.file));
|
||||
recordInjection(cwd, {
|
||||
framework: resolved?.framework.name,
|
||||
port,
|
||||
artifacts: plannedArtifacts.filter((a) => writtenFiles.has(a.path)),
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: anyInserted,
|
||||
port,
|
||||
gitIgnore,
|
||||
results,
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
@@ -271,115 +316,6 @@ export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nuxt adapter
|
||||
//
|
||||
// A script element placed in app.vue is compiled as Vue-rendered DOM and is
|
||||
// not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
|
||||
// generated, dev-only, and outside user-authored source: Live creates one
|
||||
// marked .client.ts plugin on start and removes it on stop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function detectNuxtProject(cwd = process.cwd()) {
|
||||
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/.test(entry.name))
|
||||
?.name;
|
||||
if (!configFile) return null;
|
||||
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = '';
|
||||
if (literalSrcDir) {
|
||||
const candidate = literalSrcDir[2]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
|
||||
appDir = normalized === '.' ? '' : normalized;
|
||||
}
|
||||
} else if (
|
||||
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|
||||
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
|
||||
) {
|
||||
appDir = 'app';
|
||||
}
|
||||
|
||||
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
|
||||
return { configFile, appDir, pluginFile };
|
||||
}
|
||||
|
||||
export function buildNuxtPlugin(port, token) {
|
||||
return `/* ${NUXT_PLUGIN_MARKER} */
|
||||
const liveSrc = '${buildLiveScriptSrc(port, token)}';
|
||||
const liveSelector = 'script[data-impeccable-live-nuxt]';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.dev || typeof document === 'undefined') return;
|
||||
|
||||
const expectedSrc = new URL(liveSrc, window.location.href).href;
|
||||
let script = document.querySelector(liveSelector);
|
||||
if (script?.src === expectedSrc) return;
|
||||
script?.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = liveSrc;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveNuxt = '';
|
||||
document.head.appendChild(script);
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
if (script?.isConnected) script.remove();
|
||||
});
|
||||
});
|
||||
/* /${NUXT_PLUGIN_MARKER} */
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, token, project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
|
||||
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buildNuxtPlugin(port, token);
|
||||
fs.mkdirSync(path.dirname(absFile), { recursive: true });
|
||||
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
inserted: true,
|
||||
changed: content !== existing,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
if (!fs.existsSync(absFile)) {
|
||||
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
|
||||
}
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
if (!content.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
removed: false,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.unlinkSync(absFile);
|
||||
const pluginDir = path.dirname(absFile);
|
||||
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
|
||||
return { file: project.pluginFile, removed: true };
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
@@ -527,242 +463,31 @@ function validateConfig(cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
|
||||
|
||||
/**
|
||||
* Build the /live.js src the browser loads. When a token is supplied it rides
|
||||
* as a `?token=...` query param so the server's token-gated /live.js handler
|
||||
* authorizes the fetch. Shared by every injection path (HTML/JSX script tag,
|
||||
* the Nuxt plugin, the SvelteKit root component) so they stay in sync.
|
||||
*/
|
||||
export function buildLiveScriptSrc(port, token) {
|
||||
const base = 'http://localhost:' + port + '/live.js';
|
||||
return token ? base + '?token=' + encodeURIComponent(token) : base;
|
||||
}
|
||||
|
||||
function buildTagBlock(syntax, port, filePath, token) {
|
||||
const open = commentOpen(syntax);
|
||||
const close = commentClose(syntax);
|
||||
// Astro processes <script> tags by default and rewrites src to its own
|
||||
// bundled URL. is:inline opts out so the literal external src survives.
|
||||
const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');
|
||||
const scriptAttrs = isAstro ? 'is:inline ' : '';
|
||||
return (
|
||||
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
|
||||
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
|
||||
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function detectLineEnding(content) {
|
||||
if (content.includes('\r\n')) return '\r\n';
|
||||
if (content.includes('\r')) return '\r';
|
||||
return '\n';
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content, lineEnding) {
|
||||
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
|
||||
}
|
||||
|
||||
function readLineEndingAt(content, index) {
|
||||
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
|
||||
if (content[index] === '\n') return '\n';
|
||||
if (content[index] === '\r') return '\r';
|
||||
return '';
|
||||
}
|
||||
|
||||
function insertTag(content, config, port, filePath, token) {
|
||||
const lineEnding = detectLineEnding(content);
|
||||
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding);
|
||||
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
|
||||
// belong at the end, and the same literal can appear earlier in code blocks
|
||||
// within rendered documentation pages.
|
||||
if (config.insertBefore) {
|
||||
const idx = content.lastIndexOf(config.insertBefore);
|
||||
if (idx === -1) return content;
|
||||
return content.slice(0, idx) + block + content.slice(idx);
|
||||
}
|
||||
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
|
||||
// `<body>` open near the top of the document.
|
||||
const idx = content.indexOf(config.insertAfter);
|
||||
if (idx === -1) return content;
|
||||
const after = idx + config.insertAfter.length;
|
||||
// Preserve an existing trailing newline if the anchor already has one.
|
||||
// Slice the remainder from the original anchor offset, not prefix.length:
|
||||
// in the no-newline case prefix is one char longer than the anchor (the
|
||||
// appended '\n'), so slicing by prefix.length would drop the first real
|
||||
// character after the anchor (#227).
|
||||
const existingNewline = readLineEndingAt(content, after);
|
||||
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
|
||||
const rest = content.slice(after + existingNewline.length);
|
||||
return prefix + block + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the live script block. Matches either HTML or JSX comment markers
|
||||
* regardless of config (so stale tags from a wrong config can still be cleaned).
|
||||
*
|
||||
* Indent-preserving: captures any whitespace immediately preceding the opener
|
||||
* marker and re-emits it in place of the removed block. `insertTag` inserted
|
||||
* the block *after* the original line's indent and *before* the anchor (e.g.
|
||||
* `</body>`), which moved the indent onto the opener line and left the anchor
|
||||
* unindented. Replacing the whole block (plus its trailing newline) with just
|
||||
* the captured indent hands the indent back to the anchor that follows.
|
||||
*/
|
||||
function removeTag(content, _syntax) {
|
||||
const patterns = [
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
let changed = false;
|
||||
let next = content;
|
||||
do {
|
||||
content = next;
|
||||
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
|
||||
if (/[\r\n]/.test(trailing)) return leadingIndent;
|
||||
return leadingIndent || trailing || '';
|
||||
});
|
||||
if (next !== content) changed = true;
|
||||
} while (next !== content);
|
||||
if (changed) return next;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-Security-Policy meta-tag patcher
|
||||
//
|
||||
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
|
||||
// the cross-origin load of /live.js (and the SSE/POST connection back to
|
||||
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
|
||||
//
|
||||
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
|
||||
// and stash the original `content` value in a `data-impeccable-csp-original`
|
||||
// attribute (base64) so revert is exact.
|
||||
//
|
||||
// On remove: detect the marker attribute, decode it, restore the original
|
||||
// content value verbatim, drop the marker.
|
||||
//
|
||||
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
|
||||
// shared helpers) is NOT patched here — those need framework-specific config
|
||||
// edits and are handled via the existing detect-csp.mjs reference output.
|
||||
// Only the in-source meta-tag form gets the auto-patch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
|
||||
|
||||
function findCspMetaTags(content) {
|
||||
const out = [];
|
||||
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
|
||||
let m;
|
||||
while ((m = tagRe.exec(content)) !== null) {
|
||||
const attrs = m[1];
|
||||
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
|
||||
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getAttr(attrs, name) {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
|
||||
const m = attrs.match(re);
|
||||
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
|
||||
}
|
||||
|
||||
function appendOriginToDirective(csp, directive, origin) {
|
||||
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
|
||||
const m = csp.match(re);
|
||||
if (m) {
|
||||
const tokens = m[4].trim().split(/\s+/);
|
||||
if (tokens.includes(origin)) return csp;
|
||||
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
|
||||
}
|
||||
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
|
||||
// narrow the policy compared to the default-src fallback (most users with
|
||||
// an explicit CSP have 'self' there).
|
||||
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
|
||||
}
|
||||
|
||||
export function patchCspMeta(content, port) {
|
||||
const tags = findCspMetaTags(content);
|
||||
if (tags.length === 0) return content;
|
||||
const origin = `http://localhost:${port}`;
|
||||
|
||||
// Walk last-to-first so prior splices don't invalidate later indices.
|
||||
let result = content;
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
const attrs = tag.attrs;
|
||||
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
|
||||
const contentAttr = getAttr(attrs, 'content');
|
||||
if (!contentAttr) continue;
|
||||
|
||||
const original = contentAttr.value;
|
||||
let patched = original;
|
||||
patched = appendOriginToDirective(patched, 'script-src', origin);
|
||||
patched = appendOriginToDirective(patched, 'connect-src', origin);
|
||||
// The shader overlay during 'generating' creates a screenshot via
|
||||
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
|
||||
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
|
||||
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
|
||||
if (patched === original) continue;
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function revertCspMeta(content) {
|
||||
const tags = findCspMetaTags(content);
|
||||
if (tags.length === 0) return content;
|
||||
|
||||
let result = content;
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
|
||||
if (!origAttr) continue;
|
||||
const contentAttr = getAttr(tag.attrs, 'content');
|
||||
if (!contentAttr) continue;
|
||||
|
||||
let originalValue;
|
||||
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
|
||||
catch { continue; }
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
|
||||
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
|
||||
// Drop the marker attribute and any single space immediately preceding it.
|
||||
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
|
||||
const newTag = tag.full.replace(tag.attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
|
||||
enterLiveRoot();
|
||||
injectCli();
|
||||
}
|
||||
|
||||
export { insertTag, removeTag, validateConfig, buildTagBlock };
|
||||
// patchCspMeta + revertCspMeta are exported above where they're defined.
|
||||
// Re-exported so long-standing importers (live.mjs, the adapter modules, the
|
||||
// test suites) keep their entry points while the implementations live in
|
||||
// live/frameworks/.
|
||||
export {
|
||||
buildLiveScriptSrc,
|
||||
buildTagBlock,
|
||||
insertTag,
|
||||
patchCspMeta,
|
||||
removeTag,
|
||||
revertCspMeta,
|
||||
validateConfig,
|
||||
};
|
||||
export {
|
||||
applyNuxtLiveAdapter,
|
||||
buildNuxtPlugin,
|
||||
detectNuxtProject,
|
||||
removeNuxtLiveAdapter,
|
||||
} from './live/frameworks/nuxt.mjs';
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
scaffoldSvelteComponentInsertSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const INSERT_POSITIONS = new Set(['before', 'after']);
|
||||
|
||||
@@ -286,5 +287,6 @@ Output (JSON):
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
|
||||
enterLiveRoot();
|
||||
insertCli();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
@@ -412,5 +413,6 @@ export function normalizePollTypes(value) {
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
|
||||
enterLiveRoot();
|
||||
pollCli();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
@@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) {
|
||||
return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's render truth, folded into a small block the agent reads before
|
||||
* it decides what to do. `arrivedVariants` only says the agent published;
|
||||
* `renderState` says whether any of it reached a screen.
|
||||
*/
|
||||
export function renderSummary(snapshot = {}) {
|
||||
return {
|
||||
renderState: snapshot.renderState ?? null,
|
||||
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
|
||||
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mountFailureAction(snapshot = {}) {
|
||||
const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [];
|
||||
const latest = failures[failures.length - 1];
|
||||
if (!latest) return null;
|
||||
const where = latest.url ? ` from ${latest.url}` : '';
|
||||
const why = latest.error ? ` (${latest.error})` : '';
|
||||
return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file <manifest or source path> for the queued variant_mount_failed event (or republish) so the browser retries.`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -75,20 +98,26 @@ export async function resumeCli() {
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? pending.type === 'manual_edit_apply'
|
||||
? manualApplyResumeHint(pending)
|
||||
: `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
const render = renderSummary(snapshot);
|
||||
// A failed render outranks the generic pending-event hint: the agent needs to
|
||||
// know the user is staring at an error card, not at variants. A leased manual
|
||||
// Apply still outranks both, because abandoning that lease loses user edits.
|
||||
const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null;
|
||||
const nextAction = pending?.type === 'manual_edit_apply'
|
||||
? manualApplyResumeHint(pending)
|
||||
: mountAction || (pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`);
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
enterLiveRoot();
|
||||
resumeCli();
|
||||
}
|
||||
|
||||
+117
-16
@@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs';
|
||||
import { validateEvent } from './live/event-validation.mjs';
|
||||
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
|
||||
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
|
||||
import { LIVE_COMMANDS } from './live/vocabulary.mjs';
|
||||
import {
|
||||
LIVE_COMMANDS,
|
||||
VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
|
||||
} from './live/vocabulary.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
@@ -51,24 +54,46 @@ import {
|
||||
} from './live/manual-apply.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
bumpSvelteComponentPreviewRevision,
|
||||
removeAllSvelteComponentSessions,
|
||||
sweepInactiveSvelteComponentSessions,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
// Anchor the whole process on the live roots manifest before anything derives
|
||||
// a path from cwd. A server started from the wrong directory re-roots itself
|
||||
// onto the appRoot the boot decided on instead of minting a second project.
|
||||
const LIVE_ROOTS = enterLiveRoot(process.cwd());
|
||||
|
||||
// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
|
||||
// that outlives an `impeccable document` run (or a context file created after
|
||||
// boot) reports current truth instead of a boot-time snapshot. The roots
|
||||
// manifest wins when the ambient resolution misses (nested app inheriting
|
||||
// repo-level context files).
|
||||
function resolveProjectContext() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
const designPath = ctx.designPath
|
||||
? path.resolve(process.cwd(), ctx.designPath)
|
||||
: (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
|
||||
const hasProduct = ctx.hasProduct
|
||||
|| !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
|
||||
return {
|
||||
...ctx,
|
||||
hasProduct,
|
||||
hasDesign: !!designPath,
|
||||
resolvedDesignPath: designPath,
|
||||
contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
|
||||
designContextDir: ctx.designContextDir
|
||||
|| (designPath ? path.dirname(designPath) : null),
|
||||
};
|
||||
}
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
// The browser checkpoints for several unrelated reasons (see checkpointPayload
|
||||
// in live-browser.js). Only these two report that variant availability changed,
|
||||
// and only they may drive variant_progress / the *_reviewable phases.
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
|
||||
const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
@@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
generationCompletedAt: snapshot.generationCompletedAt ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
// Render truth, so a browser with no localStorage can rehydrate to the
|
||||
// same comparison the server already knows about.
|
||||
mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
|
||||
mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
|
||||
renderState: snapshot.renderState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -618,7 +648,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
return !!resolveProjectContext().hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const projectContext = resolveProjectContext();
|
||||
const mdPath = projectContext.resolvedDesignPath;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
if (msg.type !== 'checkpoint') {
|
||||
// `variant_mounted` is the happy path: it is journaled above so the
|
||||
// snapshot carries render truth, but there is nothing for the agent to
|
||||
// do about it, so it stays out of the poll queue and off the SSE bus.
|
||||
// `variant_mount_failed` is the opposite: the agent published something
|
||||
// the browser could not render, and only the agent can fix it, so it
|
||||
// goes to the queue as a first-class event.
|
||||
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') {
|
||||
enqueueEvent(msg);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) {
|
||||
const base = { file: normalized };
|
||||
const metadataFile = normalized;
|
||||
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
|
||||
if (!metadataFile.includes('node_modules/.impeccable-live/')
|
||||
if (!metadataFile.includes('.impeccable/live/previews/')
|
||||
&& !metadataFile.includes('node_modules/.impeccable-live/')
|
||||
&& !metadataFile.includes('src/lib/impeccable/')
|
||||
&& !metadataFile.includes('/.impeccable-live/')) return base;
|
||||
|
||||
@@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
|
||||
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
|
||||
// New pollers send sourceEventType explicitly; default to generate only for
|
||||
// older callers so a late worker cannot acknowledge a queued Accept.
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
|
||||
if (msg.type === 'agent_done' || msg.type === 'done') {
|
||||
// A `done` reply to a mount failure is the republish that unblocks the
|
||||
// browser. Without this the ack would look for a `generate` that was
|
||||
// already retired, the mount-failure event would stay queued, and the next
|
||||
// poll would hand the same failure back to the agent forever.
|
||||
if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
|
||||
return 'generate';
|
||||
}
|
||||
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
|
||||
// never sets sourceEventType on it (the poller is a fresh process that cannot
|
||||
// know what it leased). Returning undefined here makes acknowledgePendingEvent
|
||||
@@ -1264,6 +1309,15 @@ function handlePollPost(req, res) {
|
||||
return;
|
||||
}
|
||||
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
|
||||
// A publish (done reply carrying a component manifest) snapshots the
|
||||
// variant files into a fresh revision dir before the browser is told:
|
||||
// the import path changes every publish, so no transform cache can pin a
|
||||
// stale compile of a republished module (node_modules is unwatched).
|
||||
if (replyFileMeta.previewMode === 'svelte-component'
|
||||
&& msg.id
|
||||
&& (msg.type === 'done' || !msg.type)) {
|
||||
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
}
|
||||
if (state.sessionStore && msg.id && !skipJournalReply) {
|
||||
try {
|
||||
const eventType = msg.type === 'steer_done'
|
||||
@@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A previous run that died without its shutdown hook leaves preview component
|
||||
* dirs behind. Drop the ones whose session the store no longer considers
|
||||
* active; anything still active is mid-generation and must survive a restart.
|
||||
*/
|
||||
function sweepOrphanSvelteComponentSessionsOnStartup() {
|
||||
try {
|
||||
const activeIds = (state.sessionStore?.listActiveSessions() || [])
|
||||
.map((snapshot) => snapshot?.id)
|
||||
.filter(Boolean);
|
||||
const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
|
||||
if (result.removed.length > 0 || result.removedRoot) {
|
||||
console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] Svelte component session sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept receipts are a short-lived idempotency record for a single accept.
|
||||
// Nothing reads one after the session that wrote it is gone, so they only need
|
||||
// to outlive a crash-and-retry window.
|
||||
const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function sweepStaleAcceptReceiptsOnStartup() {
|
||||
try {
|
||||
const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
|
||||
let removed = 0;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(file).mtimeMs >= cutoff) continue;
|
||||
fs.rmSync(file, { force: true });
|
||||
removed++;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
|
||||
} catch (err) {
|
||||
console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyDeferredAcceptsOnStartup() {
|
||||
try {
|
||||
const result = applyDeferredSvelteComponentAccepts(process.cwd());
|
||||
@@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({
|
||||
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
|
||||
});
|
||||
applyLegacyDeferredAcceptsOnStartup();
|
||||
sweepOrphanSvelteComponentSessionsOnStartup();
|
||||
sweepStaleAcceptReceiptsOnStartup();
|
||||
restorePendingEventsFromStore();
|
||||
manualApply.pruneStaleEvidence();
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { manualApplyResumeHint } from './live-resume.mjs';
|
||||
import { manualApplyResumeHint, mountFailureAction, renderSummary } from './live-resume.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
@@ -28,6 +29,8 @@ export async function statusCli() {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const manualApply = findPendingManualApply(server, activeSessions);
|
||||
const sessions = server?.activeSessions || activeSessions;
|
||||
const renderFailure = sessions.find((session) => session?.renderState === 'failed') || null;
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
@@ -36,14 +39,16 @@ export async function statusCli() {
|
||||
agentPolling: server.agentPolling,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: recoveryHint({ server, manualApply }),
|
||||
activeSessions: sessions,
|
||||
render: sessions.map((session) => ({ id: session?.id ?? null, ...renderSummary(session) })),
|
||||
recoveryHint: recoveryHint({ server, manualApply, renderFailure }),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function recoveryHint({ server, manualApply }) {
|
||||
function recoveryHint({ server, manualApply, renderFailure }) {
|
||||
if (manualApply) return manualApplyResumeHint(manualApply);
|
||||
if (renderFailure) return mountFailureAction(renderFailure);
|
||||
if (server) {
|
||||
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
|
||||
}
|
||||
@@ -61,5 +66,6 @@ function findPendingManualApply(server, activeSessions) {
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
enterLiveRoot();
|
||||
statusCli();
|
||||
}
|
||||
|
||||
+49
-31
@@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { findSourceFile } from './live/source-search.mjs';
|
||||
import { resolveSourceTraits } from './live/frameworks/index.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
export async function wrapCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
.join('\n');
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
const useFrameworkComponent = useSvelteComponent;
|
||||
// The registry says which files get component preview; the svelte-component
|
||||
// module keeps the env escape hatch that turns it off.
|
||||
const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component'
|
||||
&& shouldUseSvelteComponentInjection(targetFile);
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
let sveltePreviewFallback = null;
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
// Keep generation source-neutral: agents write real variant components
|
||||
// under the generated componentDir, the browser mounts them into the live
|
||||
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
|
||||
svelteSession = scaffoldSvelteComponentSession({
|
||||
//
|
||||
// The scaffold is AST-based and refuses markup a detached preview cannot
|
||||
// support (component tags, bind:/use:, await blocks, bound nested each).
|
||||
// Refusal falls back to the plain source-preview wrapper below: an
|
||||
// HMR-resetting but CORRECT preview beats a detached wrong one.
|
||||
const scaffolded = scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
@@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`);
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
if (scaffolded && scaffolded.fallback === 'source-preview') {
|
||||
sveltePreviewFallback = scaffolded.reason || 'unsupported markup';
|
||||
} else {
|
||||
svelteSession = scaffolded;
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
}
|
||||
}
|
||||
if (svelteSession) {
|
||||
// component preview: outputs already set above
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
@@ -396,15 +414,19 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const componentPreviewActive = !!svelteSession;
|
||||
const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const componentSession = svelteSession;
|
||||
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined;
|
||||
const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined;
|
||||
const previewMode = componentPreviewMode;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
sourceFile: componentPreviewActive ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
previewFallback: sveltePreviewFallback
|
||||
? { from: 'svelte-component', reason: sveltePreviewFallback }
|
||||
: undefined,
|
||||
// Deferred source write: the wrapper is NOT yet in source. The agent
|
||||
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
|
||||
// spliced at the "insert below this line" marker) in one atomic edit.
|
||||
@@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
|
||||
sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
|
||||
sourceEndLine: componentPreviewActive ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
@@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: componentPreviewMode || styleMode.mode,
|
||||
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
styleTag: componentPreviewActive ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
@@ -630,27 +652,22 @@ function attrEscapeDouble(str) {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment syntax, style mode, and preview strategy all come from the framework
|
||||
* registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX
|
||||
* comments, `.astro` needs global-prefixed preview CSS because Astro scopes
|
||||
* component styles away from the generated wrappers, `.svelte` gets component
|
||||
* preview. See live/frameworks/index.mjs for why extension and not project.
|
||||
*/
|
||||
function detectCommentSyntax(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.jsx' || ext === '.tsx') {
|
||||
return { open: '{/*', close: '*/}' };
|
||||
}
|
||||
// HTML, Vue, Svelte, Astro all use HTML comments
|
||||
return { open: '<!--', close: '-->' };
|
||||
return resolveSourceTraits(filePath).commentSyntax === 'jsx'
|
||||
? { open: '{/*', close: '*/}' }
|
||||
: { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
const traits = resolveSourceTraits(filePath);
|
||||
return { mode: traits.styleMode, styleTag: traits.styleTag };
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
@@ -890,6 +907,7 @@ function findClosingLine(lines, start) {
|
||||
// Auto-execute when run directly (node live-wrap.mjs ...)
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
|
||||
enterLiveRoot();
|
||||
wrapCli();
|
||||
}
|
||||
|
||||
|
||||
+53
-23
@@ -21,10 +21,11 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -60,6 +61,8 @@ The agent should then:
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Legacy workspace-monorepo selection first: it carries richer candidate
|
||||
// metadata (context inheritance status) than the roots scan.
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -71,11 +74,31 @@ The agent should then:
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const rootsResult = resolveRoots({
|
||||
cwd: liveTarget.originalCwd,
|
||||
targetPath: liveTarget.absoluteTargetPath,
|
||||
});
|
||||
if (rootsResult.selection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
targetCandidates: rootsResult.selection.candidates,
|
||||
hint: 'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
const roots = rootsResult.manifest;
|
||||
const activeCwd = roots.appRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
// Gate on readable CONTENT, not path existence, so an empty or unreadable
|
||||
// PRODUCT.md routes to init instead of passing the gate and then reporting
|
||||
// hasProduct: false in the same payload.
|
||||
const product = safeRead(roots.productPath);
|
||||
const design = safeRead(roots.designPath);
|
||||
const missingContext = [];
|
||||
if (!product) missingContext.push('PRODUCT.md');
|
||||
if (!design) missingContext.push('DESIGN.md');
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
@@ -83,14 +106,18 @@ The agent should then:
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
projectRoot: roots.appRoot,
|
||||
repoRoot: roots.repoRoot,
|
||||
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
||||
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Persist the decision before anything else spawns, so every helper the
|
||||
// agent runs later (from any cwd inside the repo) lands on the same roots.
|
||||
writeRootsManifest(roots);
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
@@ -98,8 +125,8 @@ The agent should then:
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
projectRoot: roots.appRoot,
|
||||
repoRoot: roots.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -143,22 +170,25 @@ The agent should then:
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
hasDesign: ctx.hasDesign,
|
||||
design: ctx.design,
|
||||
designPath: ctx.designPath,
|
||||
projectRoot: roots.appRoot,
|
||||
repoRoot: roots.repoRoot,
|
||||
roots,
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
function safeRead(p) {
|
||||
if (!p) return null;
|
||||
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function relOrNull(base, p) {
|
||||
return p ? path.relative(base, p) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Accept-time CSS reconciliation for live mode.
|
||||
*
|
||||
* The old accept path appended the chosen variant's whole <style> body in
|
||||
* front of the component's existing rules, which preserved every superseded
|
||||
* declaration (the "old divider borders survive the accept" bug) and left
|
||||
* dead parameter branches in source. This module makes acceptance a merge:
|
||||
*
|
||||
* reconcileCss replace rules whose selectors match, append new ones
|
||||
* bakeParamValues collapse --p-* vars and [data-p-*] branches to the
|
||||
* user's chosen values, driven by the declared param
|
||||
* kinds from params.json (not regex sniffing)
|
||||
* pruneUnusedSelectors use the framework compiler's own unused-selector
|
||||
* warnings to delete rules the accepted markup no longer
|
||||
* references
|
||||
*
|
||||
* The parser is hand-rolled on purpose: skill scripts run standalone inside
|
||||
* user projects and cannot rely on this repo's node_modules. It is a small
|
||||
* recursive block parser (comment- and string-aware), not a spec-complete
|
||||
* CSS parser; everything it emits round-trips byte-for-byte through raw
|
||||
* slices except the rules deliberately changed.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a stylesheet into a flat tree.
|
||||
* Node shapes:
|
||||
* { type: 'rule', prelude, body, start, end, preludeStart }
|
||||
* { type: 'at', name, prelude, children|body, start, end } (children when
|
||||
* the block contains rules: media/supports/layer/container/scope)
|
||||
* { type: 'comment', text, start, end }
|
||||
*/
|
||||
export function parseStylesheet(css, offset = 0) {
|
||||
const text = String(css || '');
|
||||
const nodes = [];
|
||||
let i = 0;
|
||||
|
||||
const skipWs = () => { while (i < text.length && /\s/.test(text[i])) i++; };
|
||||
|
||||
while (i < text.length) {
|
||||
skipWs();
|
||||
if (i >= text.length) break;
|
||||
|
||||
if (text[i] === '/' && text[i + 1] === '*') {
|
||||
const start = i;
|
||||
const close = text.indexOf('*/', i + 2);
|
||||
i = close === -1 ? text.length : close + 2;
|
||||
nodes.push({ type: 'comment', text: text.slice(start, i), start: offset + start, end: offset + i });
|
||||
continue;
|
||||
}
|
||||
|
||||
const preludeStart = i;
|
||||
const boundary = scanToBlockOrStatementEnd(text, i);
|
||||
if (boundary.kind === 'none') break; // trailing garbage / declarations at top level
|
||||
if (boundary.kind === 'statement') {
|
||||
// Block-less at-statement (@import, @charset, @layer names;). Emitted
|
||||
// as its own node so the FOLLOWING rule still indexes for
|
||||
// reconciliation instead of being folded into this prelude.
|
||||
const raw = text.slice(preludeStart, boundary.index + 1).trim();
|
||||
if (raw) {
|
||||
nodes.push({
|
||||
type: 'at',
|
||||
name: (raw.match(/^@([A-Za-z-]+)/) || [])[1] || '',
|
||||
prelude: raw.replace(/;$/, ''),
|
||||
statement: true,
|
||||
start: offset + preludeStart,
|
||||
end: offset + boundary.index + 1,
|
||||
});
|
||||
}
|
||||
i = boundary.index + 1;
|
||||
continue;
|
||||
}
|
||||
const braceIdx = boundary.index;
|
||||
const prelude = text.slice(preludeStart, braceIdx).trim();
|
||||
const bodyStart = braceIdx + 1;
|
||||
const bodyEnd = scanBlockEnd(text, bodyStart);
|
||||
const body = text.slice(bodyStart, bodyEnd);
|
||||
const nodeEnd = Math.min(text.length, bodyEnd + 1);
|
||||
|
||||
if (prelude.startsWith('@')) {
|
||||
const name = (prelude.match(/^@([A-Za-z-]+)/) || [])[1] || '';
|
||||
if (['media', 'supports', 'layer', 'container', 'scope'].includes(name)) {
|
||||
nodes.push({
|
||||
type: 'at',
|
||||
name,
|
||||
prelude,
|
||||
children: parseStylesheet(body, offset + bodyStart),
|
||||
start: offset + preludeStart,
|
||||
end: offset + nodeEnd,
|
||||
});
|
||||
} else {
|
||||
nodes.push({
|
||||
type: 'at',
|
||||
name,
|
||||
prelude,
|
||||
body,
|
||||
start: offset + preludeStart,
|
||||
end: offset + nodeEnd,
|
||||
});
|
||||
}
|
||||
} else if (prelude) {
|
||||
nodes.push({
|
||||
type: 'rule',
|
||||
prelude,
|
||||
body,
|
||||
start: offset + preludeStart,
|
||||
end: offset + nodeEnd,
|
||||
preludeStart: offset + preludeStart,
|
||||
});
|
||||
}
|
||||
i = nodeEnd;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for the next structural boundary: the `{` opening a block, or the `;`
|
||||
* ending a block-less at-statement, whichever comes first (string- and
|
||||
* comment-aware). Returns { kind: 'block' | 'statement' | 'none', index }.
|
||||
*/
|
||||
function scanToBlockOrStatementEnd(text, from) {
|
||||
let i = from;
|
||||
let quote = null;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === '/' && text[i + 1] === '*') {
|
||||
const close = text.indexOf('*/', i + 2);
|
||||
i = close === -1 ? text.length : close + 1;
|
||||
} else if (ch === '{') {
|
||||
return { kind: 'block', index: i };
|
||||
} else if (ch === ';') {
|
||||
return { kind: 'statement', index: i };
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return { kind: 'none', index: -1 };
|
||||
}
|
||||
|
||||
function scanBlockEnd(text, from) {
|
||||
let i = from;
|
||||
let depth = 1;
|
||||
let quote = null;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === '/' && text[i + 1] === '*') {
|
||||
const close = text.indexOf('*/', i + 2);
|
||||
i = close === -1 ? text.length : close + 1;
|
||||
} else if (ch === '{') {
|
||||
depth++;
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
export function serializeNodes(nodes, indent = '') {
|
||||
const out = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'comment') {
|
||||
out.push(indent + node.text);
|
||||
} else if (node.type === 'rule') {
|
||||
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
|
||||
} else if (node.type === 'at' && node.children) {
|
||||
out.push(`${indent}${node.prelude} {`);
|
||||
out.push(serializeNodes(node.children, indent + ' '));
|
||||
out.push(`${indent}}`);
|
||||
} else if (node.type === 'at' && node.statement) {
|
||||
out.push(`${indent}${node.prelude};`);
|
||||
} else if (node.type === 'at') {
|
||||
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function formatBody(body, indent) {
|
||||
const trimmed = String(body || '').trim();
|
||||
if (!trimmed) return ' ';
|
||||
const lines = trimmed.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 1 && lines[0].length < 60) return ` ${lines[0]} `;
|
||||
return '\n' + lines.map((l) => `${indent} ${l}`).join('\n') + `\n${indent}`;
|
||||
}
|
||||
|
||||
export function normalizeSelector(prelude) {
|
||||
return String(prelude || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s*([>+~,])\s*/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reconciliation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge variant CSS into existing CSS. Rules whose (at-context, normalized
|
||||
* selector) match an existing rule REPLACE that rule's body in place; new
|
||||
* rules append at the end under their at-context. Returns { css, replaced,
|
||||
* appended }.
|
||||
*/
|
||||
export function reconcileCss(existingCss, variantCss) {
|
||||
const existing = parseStylesheet(existingCss);
|
||||
const incoming = parseStylesheet(variantCss);
|
||||
let replaced = 0;
|
||||
let appended = 0;
|
||||
|
||||
const mergeLevel = (existingNodes, incomingNodes) => {
|
||||
const index = new Map();
|
||||
for (const node of existingNodes) {
|
||||
if (node.type === 'rule') index.set(normalizeSelector(node.prelude), node);
|
||||
}
|
||||
const atIndex = new Map();
|
||||
for (const node of existingNodes) {
|
||||
if (node.type === 'at' && node.children) atIndex.set(normalizeSelector(node.prelude), node);
|
||||
}
|
||||
// Baking can leave several incoming rules with the same selector (e.g. a
|
||||
// base rule plus a stripped param branch). The first one REPLACES the
|
||||
// existing body; later same-selector rules extend it, never clobber it.
|
||||
const touched = new Set();
|
||||
for (const node of incomingNodes) {
|
||||
if (node.type === 'comment') continue;
|
||||
if (node.type === 'rule') {
|
||||
const key = normalizeSelector(node.prelude);
|
||||
const match = index.get(key);
|
||||
if (match) {
|
||||
if (touched.has(key)) {
|
||||
match.body = `${match.body.trim()}\n${node.body.trim()}`;
|
||||
} else if (match.body.trim() !== node.body.trim()) {
|
||||
match.body = node.body;
|
||||
replaced++;
|
||||
}
|
||||
touched.add(key);
|
||||
} else {
|
||||
existingNodes.push({ ...node });
|
||||
index.set(key, existingNodes[existingNodes.length - 1]);
|
||||
touched.add(key);
|
||||
appended++;
|
||||
}
|
||||
} else if (node.type === 'at' && node.children) {
|
||||
const key = normalizeSelector(node.prelude);
|
||||
const match = atIndex.get(key);
|
||||
if (match) {
|
||||
mergeLevel(match.children, node.children);
|
||||
} else {
|
||||
existingNodes.push({ ...node });
|
||||
atIndex.set(key, existingNodes[existingNodes.length - 1]);
|
||||
appended++;
|
||||
}
|
||||
} else {
|
||||
existingNodes.push({ ...node });
|
||||
appended++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeLevel(existing, incoming);
|
||||
return { css: serializeNodes(existing), replaced, appended };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter baking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace every `var(--p-<id>, fallback)` / `var(--p-<id>)` occurrence with a
|
||||
* literal value. Paren-aware: fallbacks containing calc()/nested vars are
|
||||
* handled, unlike the old `[^)]+` regex.
|
||||
*/
|
||||
export function substituteParamVar(css, id, value) {
|
||||
const text = String(css || '');
|
||||
const needle = `var(--p-${id}`;
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const idx = text.indexOf(needle, i);
|
||||
if (idx === -1) { out += text.slice(i); break; }
|
||||
const after = idx + needle.length;
|
||||
// Must be end of the var name: `)` or `,`.
|
||||
if (after < text.length && text[after] !== ')' && text[after] !== ',') {
|
||||
out += text.slice(i, after);
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
let j = after;
|
||||
let depth = 1; // we are inside var(
|
||||
while (j < text.length && depth > 0) {
|
||||
if (text[j] === '(') depth++;
|
||||
else if (text[j] === ')') depth--;
|
||||
j++;
|
||||
}
|
||||
out += text.slice(i, idx) + String(value);
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeToggleForVar(value) {
|
||||
return value === true || value === 'true' || value === 1 || value === '1' || value === 'on' ? '1' : '0';
|
||||
}
|
||||
|
||||
function isToggleOn(value) {
|
||||
return normalizeToggleForVar(value) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `[data-p-<id>="value"]` / `[data-p-<id>]` attribute selectors from a
|
||||
* selector, deciding survival by the chosen value:
|
||||
* returns null when the selector targets a non-chosen branch (drop it),
|
||||
* otherwise the selector with the attribute test removed and any emptied
|
||||
* :global() wrappers cleaned up.
|
||||
*/
|
||||
export function stripParamSelector(selector, id, kind, chosenValue) {
|
||||
const attrRe = new RegExp(`\\[data-p-${escapeRegExp(id)}(?:=(["'])(.*?)\\1)?\\]`, 'g');
|
||||
let drop = false;
|
||||
let out = String(selector).replace(attrRe, (_m, _q, expected) => {
|
||||
if (kind === 'steps') {
|
||||
if (expected == null || String(expected) === String(chosenValue)) return '';
|
||||
drop = true;
|
||||
return '';
|
||||
}
|
||||
// toggle: attribute presence means "on".
|
||||
if (expected != null && String(expected) !== String(chosenValue) && !isToggleOn(chosenValue)) {
|
||||
drop = true;
|
||||
return '';
|
||||
}
|
||||
if (expected == null && !isToggleOn(chosenValue)) {
|
||||
drop = true;
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
if (drop) return null;
|
||||
out = out
|
||||
.replace(/:global\(\s*\)/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^\s*[>+~]\s*/, '')
|
||||
.trim();
|
||||
return out || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake chosen parameter values into CSS. `params` is the declared parameter
|
||||
* list for the accepted variant (from params.json); `values` maps id ->
|
||||
* chosen value (falling back to each param's declared default).
|
||||
*/
|
||||
export function bakeParamValues(css, params = [], values = {}) {
|
||||
let nodes = parseStylesheet(css);
|
||||
|
||||
const chosen = new Map();
|
||||
for (const param of params || []) {
|
||||
if (!param || !param.id) continue;
|
||||
const has = values && Object.prototype.hasOwnProperty.call(values, param.id);
|
||||
chosen.set(param.id, { kind: param.kind, value: has ? values[param.id] : param.default });
|
||||
}
|
||||
// Values sent for params that were never declared still bake as ranges,
|
||||
// so an out-of-sync manifest degrades to the old behavior, not to silence.
|
||||
for (const [id, value] of Object.entries(values || {})) {
|
||||
if (!chosen.has(id)) chosen.set(id, { kind: 'range', value });
|
||||
}
|
||||
|
||||
const bakeBody = (body) => {
|
||||
let out = String(body || '');
|
||||
for (const [id, { kind, value }] of chosen) {
|
||||
const literal = kind === 'toggle' ? normalizeToggleForVar(value) : String(value);
|
||||
out = substituteParamVar(out, id, literal);
|
||||
}
|
||||
// Strip the readiness sentinel as a DECLARATION, not a line: a one-line
|
||||
// rule carrying the sentinel plus real declarations must keep the rest.
|
||||
return out
|
||||
.replace(/(^|;)\s*--impeccable-variant-ready\s*:[^;{}]*/g, '$1')
|
||||
.replace(/;\s*;/g, ';')
|
||||
.replace(/^\s*;\s*/, '');
|
||||
};
|
||||
|
||||
const transform = (list) => {
|
||||
const result = [];
|
||||
for (const node of list) {
|
||||
if (node.type === 'at' && node.children) {
|
||||
const children = transform(node.children);
|
||||
if (children.length > 0) result.push({ ...node, children });
|
||||
continue;
|
||||
}
|
||||
if (node.type !== 'rule') {
|
||||
if (node.type === 'at') result.push({ ...node, body: bakeBody(node.body) });
|
||||
else result.push(node);
|
||||
continue;
|
||||
}
|
||||
const selectors = splitSelectorList(node.prelude);
|
||||
const kept = [];
|
||||
for (let selector of selectors) {
|
||||
let alive = true;
|
||||
for (const [id, { kind, value }] of chosen) {
|
||||
if (kind !== 'steps' && kind !== 'toggle') continue;
|
||||
if (!selector.includes(`data-p-${id}`)) continue;
|
||||
const next = stripParamSelector(selector, id, kind, value);
|
||||
if (next == null) { alive = false; break; }
|
||||
selector = next;
|
||||
}
|
||||
if (alive && selector.trim()) kept.push(selector.trim());
|
||||
}
|
||||
if (kept.length === 0) continue;
|
||||
const body = bakeBody(node.body);
|
||||
if (!body.trim()) continue;
|
||||
result.push({ ...node, prelude: kept.join(', '), body });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
nodes = transform(nodes);
|
||||
return serializeNodes(nodes);
|
||||
}
|
||||
|
||||
export function splitSelectorList(prelude) {
|
||||
const selectors = [];
|
||||
let start = 0;
|
||||
let bracket = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
const text = String(prelude || '');
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === '[') bracket++;
|
||||
else if (ch === ']') bracket = Math.max(0, bracket - 1);
|
||||
else if (ch === '(') paren++;
|
||||
else if (ch === ')') paren = Math.max(0, paren - 1);
|
||||
else if (ch === ',' && bracket === 0 && paren === 0) {
|
||||
selectors.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
selectors.push(text.slice(start));
|
||||
return selectors.map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiler-driven pruning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove selectors the framework compiler reports as unused from a full
|
||||
* component source. `compileFn` is the app's svelte compile; warnings with
|
||||
* code `css_unused_selector` carry character offsets into the source.
|
||||
* `skipSelectors` protects selectors that were already unused before the
|
||||
* accept: pre-existing dead rules are the user's code, not live-mode debris.
|
||||
* Returns { source, removed } where removed lists the pruned selector texts.
|
||||
*/
|
||||
export function collectUnusedSelectors(componentSource, compileFn) {
|
||||
try {
|
||||
const { warnings } = compileFn(String(componentSource || ''), { generate: false });
|
||||
return new Set((warnings || [])
|
||||
.filter((w) => w.code === 'css_unused_selector'
|
||||
&& Number.isInteger(w.start?.character)
|
||||
&& Number.isInteger(w.end?.character))
|
||||
.map((w) => String(componentSource).slice(w.start.character, w.end.character).trim()));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneUnusedSelectors(componentSource, compileFn, { skipSelectors } = {}) {
|
||||
let source = String(componentSource || '');
|
||||
const removed = [];
|
||||
const skip = skipSelectors instanceof Set ? skipSelectors : new Set(skipSelectors || []);
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let warnings;
|
||||
try {
|
||||
({ warnings } = compileFn(source, { generate: false }));
|
||||
} catch {
|
||||
return { source, removed }; // never let pruning break an accept
|
||||
}
|
||||
const unused = (warnings || [])
|
||||
.filter((w) => w.code === 'css_unused_selector'
|
||||
&& Number.isInteger(w.start?.character)
|
||||
&& Number.isInteger(w.end?.character))
|
||||
.filter((w) => !skip.has(source.slice(w.start.character, w.end.character).trim()))
|
||||
.sort((a, b) => b.start.character - a.start.character);
|
||||
if (unused.length === 0) break;
|
||||
|
||||
let next = source;
|
||||
for (const warning of unused) {
|
||||
const result = removeSelectorAt(next, warning.start.character, warning.end.character);
|
||||
if (result.changed) {
|
||||
removed.push(result.selector);
|
||||
next = result.source;
|
||||
}
|
||||
}
|
||||
if (next === source) break;
|
||||
source = next;
|
||||
}
|
||||
return { source, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the selector at [start, end) from its rule. When it is the rule's
|
||||
* only selector, remove the whole rule (prelude through closing brace).
|
||||
*/
|
||||
function removeSelectorAt(source, start, end) {
|
||||
const selector = source.slice(start, end);
|
||||
|
||||
// Find the rule boundaries around the selector.
|
||||
const braceIdx = source.indexOf('{', end);
|
||||
if (braceIdx === -1) return { changed: false, selector, source };
|
||||
const bodyEnd = scanBlockEnd(source, braceIdx + 1);
|
||||
|
||||
// Prelude spans backward from the brace to the previous } ; { or style open.
|
||||
let preludeStart = start;
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
const ch = source[i];
|
||||
if (ch === '}' || ch === '{' || ch === ';' || ch === '>') { preludeStart = i + 1; break; }
|
||||
if (i === 0) preludeStart = 0;
|
||||
}
|
||||
const prelude = source.slice(preludeStart, braceIdx);
|
||||
const selectors = splitSelectorList(prelude);
|
||||
const target = selector.trim();
|
||||
const kept = selectors.filter((s) => s !== target);
|
||||
|
||||
if (kept.length === selectors.length) {
|
||||
// Offsets did not line up with a full selector in the list; be safe.
|
||||
return { changed: false, selector, source };
|
||||
}
|
||||
|
||||
if (kept.length === 0) {
|
||||
// Remove the entire rule including trailing newline.
|
||||
let ruleEnd = Math.min(source.length, bodyEnd + 1);
|
||||
while (ruleEnd < source.length && source[ruleEnd] === '\n') ruleEnd++;
|
||||
let ruleStart = preludeStart;
|
||||
while (ruleStart > 0 && (source[ruleStart - 1] === ' ' || source[ruleStart - 1] === '\t')) ruleStart--;
|
||||
return { changed: true, selector: target, source: source.slice(0, ruleStart) + source.slice(ruleEnd) };
|
||||
}
|
||||
|
||||
const indent = (prelude.match(/^\s*/) || [''])[0];
|
||||
return {
|
||||
changed: true,
|
||||
selector: target,
|
||||
source: source.slice(0, preludeStart) + indent + kept.join(', ') + ' ' + source.slice(braceIdx, source.length),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every normalized selector in a CSS text, including inside nested
|
||||
* at-blocks. Used by the accept postcondition: a selector present before the
|
||||
* accept may only disappear if the compiler reported it unused; anything
|
||||
* else means the parser or reconciler damaged the user's file, and the write
|
||||
* must be refused rather than silently committed.
|
||||
*/
|
||||
export function collectAllSelectors(css, out = new Set()) {
|
||||
for (const node of parseStylesheet(css)) {
|
||||
if (node.type === 'rule') {
|
||||
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
|
||||
} else if (node.type === 'at' && node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'rule') {
|
||||
for (const selector of splitSelectorList(child.prelude)) out.add(normalizeSelector(selector));
|
||||
} else if (child.type === 'at' && child.children) {
|
||||
collectSelectorsFromNodes(child.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSelectorsFromNodes(nodes, out) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'rule') {
|
||||
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
|
||||
} else if (node.type === 'at' && node.children) {
|
||||
collectSelectorsFromNodes(node.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Postcondition scanner for accepted/carbonized source. The carbonize
|
||||
* contract used to exist only as prose in reference/live.md; nothing checked
|
||||
* that an accept actually left the file clean, so dead param branches,
|
||||
* preview attributes, and marker comments accumulated across sessions. This
|
||||
* scanner is the mechanical form of that contract. live-complete refuses to
|
||||
* mark a carbonize session complete while the file is dirty, and the
|
||||
* mechanical Svelte accept runs it on its own output as a self-check.
|
||||
*/
|
||||
|
||||
const FORBIDDEN = [
|
||||
{ marker: 'impeccable-variants-start', why: 'variant wrapper comment left in source' },
|
||||
{ marker: 'impeccable-variants-end', why: 'variant wrapper comment left in source' },
|
||||
{ marker: 'impeccable-carbonize-start', why: 'carbonize block not rewritten into permanent form' },
|
||||
{ marker: 'impeccable-carbonize-end', why: 'carbonize block not rewritten into permanent form' },
|
||||
{ marker: 'impeccable-param-values', why: 'param-values comment not baked and removed' },
|
||||
{ marker: 'data-impeccable-', why: 'live-mode plumbing attribute left on markup' },
|
||||
{ marker: 'data-p-', why: 'preview parameter attribute left on markup' },
|
||||
{ marker: 'var(--p-', why: 'preview parameter variable not baked to a literal' },
|
||||
{ marker: '--impeccable-variant-ready', why: 'preview readiness sentinel left in CSS' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan file text for live-mode leftovers. Returns { clean, findings } where
|
||||
* each finding is { marker, line, excerpt, why }.
|
||||
*/
|
||||
export function verifyAcceptedSource(text) {
|
||||
const findings = [];
|
||||
const lines = String(text || '').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
for (const { marker, why } of FORBIDDEN) {
|
||||
if (line.includes(marker)) {
|
||||
findings.push({
|
||||
marker,
|
||||
line: i + 1,
|
||||
excerpt: line.trim().slice(0, 120),
|
||||
why,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { clean: findings.length === 0, findings };
|
||||
}
|
||||
|
||||
/** Convenience wrapper for CLI callers: read + scan, tolerating a missing file. */
|
||||
export function verifyAcceptedFile(fs, filePath) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return { clean: true, findings: [], missing: true };
|
||||
}
|
||||
return { ...verifyAcceptedSource(text), missing: false };
|
||||
}
|
||||
@@ -5,17 +5,26 @@
|
||||
|
||||
import { canCreateInsert } from './insert-ui.mjs';
|
||||
|
||||
// The accepted visual action values come from the canonical vocabulary so the
|
||||
// validator, the picker UI, and the marketing demo never drift. Imported (not
|
||||
// just re-exported) so it is also in scope for the validators below.
|
||||
import { VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { VISUAL_ACTIONS };
|
||||
// The accepted protocol values come from the canonical vocabulary so the
|
||||
// validator, the store, the server, and the picker UI never drift. Imported
|
||||
// (not just re-exported) so they are also in scope for the validators below.
|
||||
import { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS };
|
||||
|
||||
const AGENT_PHASE_SET = new Set(AGENT_PHASES);
|
||||
|
||||
const ID_PATTERN = /^[0-9a-f]{8}$/;
|
||||
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
|
||||
const INSERT_POSITIONS = new Set(['before', 'after']);
|
||||
const FORBIDDEN_MANUAL_EDIT_TEXT_CHARS = ['<', '{', '}', '`'];
|
||||
|
||||
// Mount acknowledgements carry a module URL and a raw exception message from
|
||||
// the page. Both are attacker-adjacent (any script on the page can POST them
|
||||
// with the token it can already read), so they are length-capped before they
|
||||
// reach the journal.
|
||||
export const MOUNT_URL_MAX_LENGTH = 2000;
|
||||
export const MOUNT_ERROR_MAX_LENGTH = 1000;
|
||||
|
||||
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
|
||||
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
|
||||
|
||||
@@ -92,6 +101,36 @@ function validateManualEditEvent(msg, label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidMountVariant(value) {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount acknowledgements are the browser's answer to "did the thing you
|
||||
* published actually render". They are validated strictly because the render
|
||||
* truth in the session snapshot is built from them: a malformed ack that slid
|
||||
* through would report a variant as mounted that never was.
|
||||
*/
|
||||
function validateMountAck(msg) {
|
||||
if (!isValidId(msg.id)) return 'variant_mounted: missing or malformed id';
|
||||
if (!isValidMountVariant(msg.variant)) return 'variant_mounted: variant must be an integer 1-999';
|
||||
if (msg.url !== undefined) {
|
||||
if (typeof msg.url !== 'string') return 'variant_mounted: url must be string';
|
||||
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mounted: url too long';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateMountFailure(msg) {
|
||||
if (!isValidId(msg.id)) return 'variant_mount_failed: missing or malformed id';
|
||||
if (!isValidMountVariant(msg.variant)) return 'variant_mount_failed: variant must be an integer 1-999';
|
||||
if (typeof msg.url !== 'string' || !msg.url.trim()) return 'variant_mount_failed: url required';
|
||||
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mount_failed: url too long';
|
||||
if (typeof msg.error !== 'string' || !msg.error.trim()) return 'variant_mount_failed: error required';
|
||||
if (msg.error.length > MOUNT_ERROR_MAX_LENGTH) return 'variant_mount_failed: error too long';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateEvent(msg) {
|
||||
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
|
||||
switch (msg.type) {
|
||||
@@ -120,13 +159,21 @@ export function validateEvent(msg) {
|
||||
return null;
|
||||
case 'agent_phase':
|
||||
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
|
||||
if (typeof msg.phase !== 'string' || !/^[a-z][a-z0-9_]{1,63}$/.test(msg.phase)) {
|
||||
return 'agent_phase: missing or malformed phase';
|
||||
if (typeof msg.phase !== 'string' || !msg.phase) return 'agent_phase: missing phase';
|
||||
// The enum, not a shape pattern. A phase the browser cannot rank is a
|
||||
// phase the progress bar cannot show, so accepting an arbitrary
|
||||
// lowercase word only defers the failure to the UI.
|
||||
if (!AGENT_PHASE_SET.has(msg.phase)) {
|
||||
return 'agent_phase: unknown phase ' + msg.phase + ' (expected one of ' + AGENT_PHASES.join(', ') + ')';
|
||||
}
|
||||
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
|
||||
return 'agent_phase: durationMs must be a non-negative number';
|
||||
}
|
||||
return null;
|
||||
case 'variant_mounted':
|
||||
return validateMountAck(msg);
|
||||
case 'variant_mount_failed':
|
||||
return validateMountFailure(msg);
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Astro registry entry.
|
||||
*
|
||||
* Astro takes the generic tag strategy, with two Astro-specific values that
|
||||
* used to sit as inline `endsWith('.astro')` branches in live-inject.mjs and
|
||||
* live-wrap.mjs:
|
||||
*
|
||||
* injectScriptAttrs Astro processes <script> tags by default and rewrites
|
||||
* src to its own bundled URL; is:inline opts out.
|
||||
* styleMode Astro scopes component styles, which strips preview CSS
|
||||
* off the generated variant wrappers, so preview rules are
|
||||
* authored global and prefixed instead of @scope'd.
|
||||
*/
|
||||
|
||||
import { findConfigFile, hasAnyDependency, literalConfigFiles } from './detect-utils.mjs';
|
||||
|
||||
const ASTRO_CONFIG_RE = /^astro\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectAstroProject(cwd = process.cwd(), config = null) {
|
||||
const configFile = findConfigFile(cwd, ASTRO_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['astro'])) return { configFile: null, via: 'package' };
|
||||
// A tree of .astro entry templates with no astro.config still belongs to
|
||||
// Astro; the configured injection target names it.
|
||||
const entry = literalConfigFiles(cwd, config).find((rel) => rel.endsWith('.astro'));
|
||||
if (entry) return { configFile: null, via: 'config-files', entry };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const astro = {
|
||||
name: 'astro',
|
||||
|
||||
detect(cwd, config) {
|
||||
return detectAstroProject(cwd, config);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.astro'],
|
||||
preview: 'source',
|
||||
styleMode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
commentSyntax: 'html',
|
||||
injectScriptAttrs: 'is:inline ',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Small read-only probes the framework entries share.
|
||||
*
|
||||
* Every helper here is cheap and failure-tolerant: detection runs on every
|
||||
* inject, against project trees that may be half-installed, so a missing or
|
||||
* malformed file means "not this framework", never a throw.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Merged dependency names from package.json, or an empty object. */
|
||||
export function readPackageDeps(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
return {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnyDependency(cwd, names) {
|
||||
const deps = readPackageDeps(cwd);
|
||||
return names.some((name) => Boolean(deps[name]));
|
||||
}
|
||||
|
||||
/** First top-level file name matching `re`, or null. */
|
||||
export function findConfigFile(cwd, re) {
|
||||
try {
|
||||
return fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && re.test(entry.name))
|
||||
?.name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function fileExists(cwd, rel) {
|
||||
try {
|
||||
return fs.existsSync(path.join(cwd, rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function firstExistingFile(cwd, candidates) {
|
||||
for (const rel of candidates) {
|
||||
if (fileExists(cwd, rel)) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal (non-glob) entries of `config.files` that exist on disk. Several
|
||||
* detectors read the configured injection target as a signal, which is how the
|
||||
* bare fixtures — a tree of `.astro` files with no astro.config — still resolve
|
||||
* to the framework that authored them.
|
||||
*/
|
||||
export function literalConfigFiles(cwd, config) {
|
||||
const files = Array.isArray(config?.files) ? config.files : [];
|
||||
const out = [];
|
||||
for (const rel of files) {
|
||||
if (typeof rel !== 'string' || rel.includes('*') || rel.includes('?')) continue;
|
||||
const normalized = rel.split(path.sep).join('/');
|
||||
if (fileExists(cwd, normalized)) out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The live-mode framework registry.
|
||||
*
|
||||
* Before this existed, framework knowledge was smeared across live-inject.mjs
|
||||
* (detection order, the Nuxt adapter, the Astro `is:inline` branch), the two
|
||||
* adapter modules, and live-wrap.mjs (which extension gets component preview,
|
||||
* which gets Astro's global-prefixed CSS, which gets JSX comments). Adding or
|
||||
* fixing a framework meant reading all of them.
|
||||
*
|
||||
* One entry per framework now declares everything the live scripts need:
|
||||
*
|
||||
* name stable identifier; also the `adapter` value in inject JSON.
|
||||
* detect (cwd, config) → falsy when this is not the project, otherwise
|
||||
* a truthy project descriptor that apply/remove/artifacts read.
|
||||
* Order in FRAMEWORKS is priority order; first truthy wins.
|
||||
* inject { kind: 'adapter', apply, remove, ignorePatterns, artifacts,
|
||||
* unpatch } for frameworks that server-render their document
|
||||
* shell, or { kind: 'tag' } for the generic marker-wrapped
|
||||
* <script src> block.
|
||||
* source how live-wrap treats files this framework authors:
|
||||
* extensions, preview ('source' | 'component'), styleMode,
|
||||
* styleTag, commentSyntax, injectScriptAttrs. Anything omitted
|
||||
* falls back to SOURCE_TRAIT_DEFAULTS.
|
||||
*
|
||||
* Two rules hold the thing together:
|
||||
*
|
||||
* 1. **Detection order is injection priority.** SvelteKit → Nuxt → TanStack
|
||||
* Start → Astro → Next → Vite → static HTML, exactly the order
|
||||
* live-inject.mjs used to hard-code. static-html always matches, so
|
||||
* resolveFramework never returns null.
|
||||
* 2. **Source traits resolve by file extension, not by project.** A SvelteKit
|
||||
* project's injection target is `src/app.html`; a Vite app can contain
|
||||
* `.astro` partials. live-wrap has always keyed these off the target file,
|
||||
* and resolveSourceTraits keeps it that way. Several entries may claim the
|
||||
* same extension (`.tsx` belongs to three); when they do, the values must
|
||||
* agree, which tests/live-frameworks.test.mjs asserts.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { sveltekit } from './sveltekit.mjs';
|
||||
import { nuxt } from './nuxt.mjs';
|
||||
import { tanstackStart } from './tanstack-start.mjs';
|
||||
import { astro } from './astro.mjs';
|
||||
import { nextjs } from './nextjs.mjs';
|
||||
import { viteGeneric } from './vite-generic.mjs';
|
||||
import { staticHtml } from './static-html.mjs';
|
||||
import { TAG_PATCH_MARKERS, unpatchTagFile } from './tag-strategy.mjs';
|
||||
|
||||
/** Priority order. Do not reorder without re-reading rule 1 above. */
|
||||
export const FRAMEWORKS = Object.freeze([
|
||||
sveltekit,
|
||||
nuxt,
|
||||
tanstackStart,
|
||||
astro,
|
||||
nextjs,
|
||||
viteGeneric,
|
||||
staticHtml,
|
||||
]);
|
||||
|
||||
export const PREVIEW_MODES = Object.freeze(['source', 'component']);
|
||||
export const STYLE_MODES = Object.freeze(['scoped', 'astro-global-prefixed']);
|
||||
export const COMMENT_SYNTAXES = Object.freeze(['html', 'jsx']);
|
||||
export const INJECT_KINDS = Object.freeze(['adapter', 'tag']);
|
||||
|
||||
export const SOURCE_TRAIT_DEFAULTS = Object.freeze({
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
commentSyntax: 'html',
|
||||
injectScriptAttrs: '',
|
||||
});
|
||||
|
||||
/** The patch kind the generic tag strategy records in the journal. */
|
||||
export const TAG_PATCH_KIND = 'live-tag';
|
||||
|
||||
/**
|
||||
* Undo functions keyed by the `patch` value an artifact carries. Built from
|
||||
* the entries so a new adapter registers its own undo alongside its apply.
|
||||
*/
|
||||
export const PATCH_UNDOERS = Object.freeze(Object.assign(
|
||||
{ [TAG_PATCH_KIND]: unpatchTagFile },
|
||||
...FRAMEWORKS.map((framework) => framework.inject.unpatch || {}),
|
||||
));
|
||||
|
||||
/**
|
||||
* First entry whose detect() matches. Returns { framework, project } where
|
||||
* project is the detector's descriptor (adapters read it; tag frameworks
|
||||
* mostly ignore it).
|
||||
*/
|
||||
export function resolveFramework(cwd = process.cwd(), config = null) {
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const project = framework.detect(cwd, config);
|
||||
if (project) return { framework, project };
|
||||
}
|
||||
// Unreachable while static-html stays terminal, but a caller that reorders
|
||||
// the array should get a diagnosable null rather than a silent tag inject.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-authoring traits for one file, merged over SOURCE_TRAIT_DEFAULTS.
|
||||
* `framework` names the entry that claimed the extension, or null.
|
||||
*/
|
||||
export function resolveSourceTraits(filePath) {
|
||||
const ext = path.extname(String(filePath || '')).toLowerCase();
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const source = framework.source;
|
||||
if (!source || !source.extensions.includes(ext)) continue;
|
||||
const { extensions, ...traits } = source;
|
||||
return { framework: framework.name, ...SOURCE_TRAIT_DEFAULTS, ...traits };
|
||||
}
|
||||
return { framework: null, ...SOURCE_TRAIT_DEFAULTS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra gitignore patterns the resolved framework needs beyond the static
|
||||
* LIVE_IGNORE_PATTERNS list (paths that depend on a detected srcDir or file
|
||||
* extension and so cannot be written down ahead of time).
|
||||
*/
|
||||
export function frameworkIgnorePatterns(resolved) {
|
||||
const fn = resolved?.framework?.inject?.ignorePatterns;
|
||||
return typeof fn === 'function' ? (fn(resolved.project) || []) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The files this injection will create or patch, in journal-artifact form.
|
||||
* Adapters declare their own; the tag strategy patches exactly the resolved
|
||||
* config files.
|
||||
*/
|
||||
export function describeInjectArtifacts(resolved, { cwd = process.cwd(), files = [] } = {}) {
|
||||
if (!resolved) return [];
|
||||
const { framework, project } = resolved;
|
||||
if (framework.inject.kind === 'adapter') {
|
||||
return (framework.inject.artifacts?.({ cwd, project }) || []).filter((a) => a && a.path);
|
||||
}
|
||||
return files.map((file) => ({
|
||||
kind: 'patched',
|
||||
path: file,
|
||||
patch: TAG_PATCH_KIND,
|
||||
markers: [...TAG_PATCH_MARKERS],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Crash-safe injection journal.
|
||||
*
|
||||
* Injection writes into the user's source tree: generated components, a Nuxt
|
||||
* client plugin, marker blocks inside a layout, a patched CSP meta tag. The
|
||||
* clean path removes all of it on stop. The unclean paths do not:
|
||||
*
|
||||
* - the dev server is SIGKILLed, so `--remove` never runs;
|
||||
* - the project changes shape between start and stop (a nuxt.config appears,
|
||||
* a package.json is edited), so detection resolves a different framework
|
||||
* and the old framework's artifacts are nobody's business;
|
||||
* - stop runs from a different directory than start did.
|
||||
*
|
||||
* So every inject records what it wrote to `.impeccable/live/inject-journal.json`
|
||||
* before the next one runs, and both inject and `--remove` reconcile that
|
||||
* record against the tree.
|
||||
*
|
||||
* **The journal is a claim of ownership, not a to-do list.** Healing an
|
||||
* artifact only ever removes what still carries our marker; a generated file
|
||||
* the user has since replaced, or a layout they have since un-patched by hand,
|
||||
* is dropped from the journal untouched.
|
||||
*
|
||||
* **Path resolution is appRoot-relative.** Live entry scripts chdir onto the
|
||||
* roots manifest (`enterLiveRoot`) before doing anything, so a journal written
|
||||
* by a session started in the app root is found by a stop issued from any
|
||||
* directory inside the repo.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PATCH_UNDOERS } from './index.mjs';
|
||||
|
||||
export const INJECT_JOURNAL_VERSION = 1;
|
||||
export const INJECT_JOURNAL_RELPATH = '.impeccable/live/inject-journal.json';
|
||||
|
||||
export function injectJournalPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, ...INJECT_JOURNAL_RELPATH.split('/'));
|
||||
}
|
||||
|
||||
export function readInjectJournal(cwd = process.cwd()) {
|
||||
const file = injectJournalPath(cwd);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.artifacts)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function clearInjectJournal(cwd = process.cwd()) {
|
||||
try { fs.unlinkSync(injectJournalPath(cwd)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
function writeInjectJournal(cwd, journal) {
|
||||
const file = injectJournalPath(cwd);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the artifacts an injection just wrote. Replaces any previous record:
|
||||
* callers heal first (see healInjectJournal), so nothing survivable is lost.
|
||||
*/
|
||||
export function recordInjection(cwd = process.cwd(), { framework, port, artifacts = [] } = {}) {
|
||||
if (!artifacts.length) {
|
||||
clearInjectJournal(cwd);
|
||||
return null;
|
||||
}
|
||||
return writeInjectJournal(cwd, {
|
||||
version: INJECT_JOURNAL_VERSION,
|
||||
appRoot: path.resolve(cwd),
|
||||
framework: framework || null,
|
||||
port: Number.isFinite(Number(port)) ? Number(port) : null,
|
||||
pid: process.pid,
|
||||
recordedAt: new Date().toISOString(),
|
||||
artifacts,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRel(cwd, rel) {
|
||||
return path.resolve(cwd, String(rel || '')).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function readIfPresent(abs) {
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDirs(dir, stopDir) {
|
||||
let current = path.resolve(dir);
|
||||
const stop = path.resolve(stopDir);
|
||||
while (current !== stop && current.startsWith(stop + path.sep)) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
function insideProject(cwd, abs) {
|
||||
const rel = path.relative(path.resolve(cwd), path.resolve(abs));
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function healArtifact(cwd, artifact, undoers) {
|
||||
const abs = path.resolve(cwd, artifact.path);
|
||||
// The journal is a project-local file, i.e. attacker-writable input in a
|
||||
// cloned repo. Never touch anything outside the project tree, whatever the
|
||||
// journal claims to own.
|
||||
if (!insideProject(cwd, abs)) return { path: artifact.path, action: 'refused_outside_project' };
|
||||
const content = readIfPresent(abs);
|
||||
if (content === null) return { path: artifact.path, action: 'absent' };
|
||||
|
||||
if (artifact.kind === 'created') {
|
||||
// Only reclaim a generated file that still carries our marker; a created
|
||||
// artifact with no marker at all is unverifiable and stays untouched.
|
||||
if (!artifact.marker || !content.includes(artifact.marker)) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
try { fs.rmSync(abs, { force: true }); } catch { return null; }
|
||||
if (artifact.pruneTo !== undefined) {
|
||||
const pruneRoot = path.resolve(cwd, artifact.pruneTo || '.');
|
||||
if (insideProject(cwd, pruneRoot) || pruneRoot === path.resolve(cwd)) {
|
||||
pruneEmptyDirs(path.dirname(abs), pruneRoot);
|
||||
}
|
||||
}
|
||||
return { path: artifact.path, action: 'removed' };
|
||||
}
|
||||
|
||||
if (artifact.kind === 'patched') {
|
||||
const markers = Array.isArray(artifact.markers) ? artifact.markers : [];
|
||||
// No marker left means the patch is already gone; never run an undo over
|
||||
// a file we no longer recognize (the undoers normalize whitespace).
|
||||
if (markers.length && !markers.some((marker) => content.includes(marker))) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
const undo = undoers[artifact.patch];
|
||||
if (typeof undo !== 'function') return null;
|
||||
const next = undo(content);
|
||||
if (next === content) return { path: artifact.path, action: 'disowned' };
|
||||
try { fs.writeFileSync(abs, next, 'utf-8'); } catch { return null; }
|
||||
return { path: artifact.path, action: 'unpatched' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the journal against the tree.
|
||||
*
|
||||
* `keep` is the set of paths the current operation legitimately owns — the
|
||||
* artifacts an inject is about to (re)write. Everything else in the journal is
|
||||
* an orphan of a session that is gone, and gets healed. This keeps a repeat
|
||||
* inject byte-idempotent: the artifacts it is about to rewrite are kept, not
|
||||
* torn down and rebuilt.
|
||||
*
|
||||
* Returns `{ healed, kept }`. `healed` lists only artifacts whose file was
|
||||
* actually changed or removed, so callers can stay silent when nothing was
|
||||
* orphaned. Idempotent: a second call finds an empty journal.
|
||||
*/
|
||||
export function healInjectJournal(cwd = process.cwd(), { keep = [], undoers = PATCH_UNDOERS } = {}) {
|
||||
const journal = readInjectJournal(cwd);
|
||||
if (!journal) return { healed: [], kept: [] };
|
||||
|
||||
const keepSet = new Set(keep.map((rel) => normalizeRel(cwd, rel)));
|
||||
const healed = [];
|
||||
const kept = [];
|
||||
|
||||
for (const artifact of journal.artifacts) {
|
||||
if (!artifact || typeof artifact.path !== 'string') continue;
|
||||
if (keepSet.has(normalizeRel(cwd, artifact.path))) {
|
||||
kept.push(artifact);
|
||||
continue;
|
||||
}
|
||||
const outcome = healArtifact(cwd, artifact, undoers);
|
||||
if (outcome && (outcome.action === 'removed' || outcome.action === 'unpatched')) {
|
||||
healed.push(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.length) {
|
||||
writeInjectJournal(cwd, { ...journal, artifacts: kept });
|
||||
} else {
|
||||
clearInjectJournal(cwd);
|
||||
}
|
||||
|
||||
return { healed, kept };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Next.js registry entry.
|
||||
*
|
||||
* Next takes the generic tag strategy: the App Router's root layout renders
|
||||
* `<html>…<body>` in JSX, so the marker-wrapped script block goes in there
|
||||
* verbatim. Nothing about injection differs from a plain Vite app, which is
|
||||
* why live-inject.mjs never had a Next branch. The entry exists so the
|
||||
* registry can name what it is looking at.
|
||||
*/
|
||||
|
||||
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
|
||||
|
||||
const NEXT_CONFIG_RE = /^next\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
const ROUTER_ENTRY_CANDIDATES = [
|
||||
'app/layout.tsx', 'app/layout.jsx', 'app/layout.ts', 'app/layout.js',
|
||||
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.ts', 'src/app/layout.js',
|
||||
'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.ts', 'pages/_app.js',
|
||||
'pages/_document.tsx', 'pages/_document.jsx',
|
||||
'src/pages/_app.tsx', 'src/pages/_app.jsx',
|
||||
];
|
||||
|
||||
export function detectNextProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, NEXT_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['next'])) return { configFile: null, via: 'package' };
|
||||
// Next's file conventions are distinctive enough to stand alone: a root
|
||||
// `app/layout.*` or `pages/_app.*` is not a shape other bundlers produce.
|
||||
const entry = ROUTER_ENTRY_CANDIDATES.find((rel) => fileExists(cwd, rel));
|
||||
if (entry) return { configFile: null, via: 'router-entry', entry };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const nextjs = {
|
||||
name: 'nextjs',
|
||||
|
||||
detect(cwd) {
|
||||
return detectNextProject(cwd);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Nuxt registry entry, and the Nuxt adapter itself.
|
||||
*
|
||||
* A script element placed in app.vue is compiled as Vue-rendered DOM and is
|
||||
* not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
|
||||
* generated, dev-only, and outside user-authored source: Live creates one
|
||||
* marked .client.ts plugin on start and removes it on stop.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from './script-src.mjs';
|
||||
import { findConfigFile } from './detect-utils.mjs';
|
||||
|
||||
export const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
|
||||
export const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
|
||||
|
||||
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectNuxtProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, NUXT_CONFIG_RE);
|
||||
if (!configFile) return null;
|
||||
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = '';
|
||||
if (literalSrcDir) {
|
||||
const candidate = literalSrcDir[2]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
|
||||
appDir = normalized === '.' ? '' : normalized;
|
||||
}
|
||||
} else if (
|
||||
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|
||||
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
|
||||
) {
|
||||
appDir = 'app';
|
||||
}
|
||||
|
||||
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
|
||||
return { configFile, appDir, pluginFile };
|
||||
}
|
||||
|
||||
export function buildNuxtPlugin(port, token) {
|
||||
return `/* ${NUXT_PLUGIN_MARKER} */
|
||||
const liveSrc = '${buildLiveScriptSrc(port, token)}';
|
||||
const liveSelector = 'script[data-impeccable-live-nuxt]';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.dev || typeof document === 'undefined') return;
|
||||
|
||||
const expectedSrc = new URL(liveSrc, window.location.href).href;
|
||||
let script = document.querySelector(liveSelector);
|
||||
if (script?.src === expectedSrc) return;
|
||||
script?.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = liveSrc;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveNuxt = '';
|
||||
document.head.appendChild(script);
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
if (script?.isConnected) script.remove();
|
||||
});
|
||||
});
|
||||
/* /${NUXT_PLUGIN_MARKER} */
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, token, project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
|
||||
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buildNuxtPlugin(port, token);
|
||||
fs.mkdirSync(path.dirname(absFile), { recursive: true });
|
||||
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
inserted: true,
|
||||
changed: content !== existing,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
if (!fs.existsSync(absFile)) {
|
||||
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
|
||||
}
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
if (!content.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
removed: false,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.unlinkSync(absFile);
|
||||
const pluginDir = path.dirname(absFile);
|
||||
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
|
||||
return { file: project.pluginFile, removed: true };
|
||||
}
|
||||
|
||||
export const nuxt = {
|
||||
name: 'nuxt',
|
||||
|
||||
detect(cwd) {
|
||||
return detectNuxtProject(cwd);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, project }) {
|
||||
return applyNuxtLiveAdapter({ cwd, port, token, project });
|
||||
},
|
||||
|
||||
remove({ cwd, project }) {
|
||||
return removeNuxtLiveAdapter({ cwd, project });
|
||||
},
|
||||
|
||||
// The plugin path depends on the resolved srcDir, so it cannot live in the
|
||||
// static ignore list the way the SvelteKit paths do.
|
||||
ignorePatterns(project) {
|
||||
return project?.pluginFile ? [project.pluginFile] : [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
if (!project?.pluginFile) return [];
|
||||
return [{
|
||||
kind: 'created',
|
||||
path: project.pluginFile,
|
||||
marker: NUXT_PLUGIN_MARKER,
|
||||
// Mirrors removeNuxtLiveAdapter: the generated `plugins/` directory
|
||||
// goes when it empties, its parent stays.
|
||||
pruneTo: path.posix.dirname(path.posix.dirname(project.pluginFile)),
|
||||
}];
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.vue'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The one place that builds the `/live.js` URL the browser loads.
|
||||
*
|
||||
* Every injection path needs it (the generic script tag, the Nuxt client
|
||||
* plugin, the SvelteKit root component, the TanStack mount component), and a
|
||||
* separate module keeps that shared leaf free of import cycles: the framework
|
||||
* entries import it, and nothing here imports a framework entry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* When a token is supplied it rides as a `?token=...` query param so the
|
||||
* server's token-gated /live.js handler authorizes the fetch.
|
||||
*/
|
||||
export function buildLiveScriptSrc(port, token) {
|
||||
const base = 'http://localhost:' + port + '/live.js';
|
||||
return token ? base + '?token=' + encodeURIComponent(token) : base;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Static HTML registry entry: the terminal fallback.
|
||||
*
|
||||
* Hand-written pages, a multi-page site emitted by a generator, anything with
|
||||
* no bundler config at the app root. `detect` always matches, so this entry
|
||||
* must stay last in FRAMEWORKS. Its behavior is the plain tag strategy, which
|
||||
* is what live-inject.mjs did for every unrecognized project before the
|
||||
* registry existed.
|
||||
*/
|
||||
|
||||
export const staticHtml = {
|
||||
name: 'static-html',
|
||||
|
||||
detect() {
|
||||
return { via: 'fallback' };
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.html', '.htm'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SvelteKit registry entry.
|
||||
*
|
||||
* Detection and the apply/remove pair are the existing adapter's
|
||||
* (`../sveltekit-adapter.mjs`); this file only declares them to the registry
|
||||
* and names the artifacts the journal has to be able to heal.
|
||||
*/
|
||||
|
||||
import {
|
||||
SVELTE_LAYOUT_MARKER_OPEN,
|
||||
SVELTE_LIVE_ROOT_COMPONENT,
|
||||
applySvelteKitLiveAdapter,
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
unpatchSvelteLayout,
|
||||
} from '../sveltekit-adapter.mjs';
|
||||
|
||||
export const sveltekit = {
|
||||
name: 'sveltekit',
|
||||
|
||||
detect(cwd, config) {
|
||||
return detectSvelteKitProject(cwd, config);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, config }) {
|
||||
return applySvelteKitLiveAdapter({ cwd, port, token, config });
|
||||
},
|
||||
|
||||
remove({ cwd, config }) {
|
||||
return removeSvelteKitLiveAdapter({ cwd, config });
|
||||
},
|
||||
|
||||
// The generated root component and the `src/lib/impeccable/` runtime paths
|
||||
// are already in the static LIVE_IGNORE_PATTERNS list, so nothing extra.
|
||||
ignorePatterns() {
|
||||
return [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
return [
|
||||
{
|
||||
kind: 'created',
|
||||
path: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
marker: 'impeccable-live-root',
|
||||
pruneTo: 'src',
|
||||
},
|
||||
{
|
||||
kind: 'patched',
|
||||
path: project?.layoutFile || 'src/routes/+layout.svelte',
|
||||
patch: 'sveltekit-layout',
|
||||
markers: [SVELTE_LAYOUT_MARKER_OPEN],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
unpatch: {
|
||||
'sveltekit-layout': unpatchSvelteLayout,
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.svelte'],
|
||||
// Svelte resets component-local state on markup HMR updates, so variants
|
||||
// are mounted from generated components rather than written into the route.
|
||||
preview: 'component',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The generic `tag` injection strategy.
|
||||
*
|
||||
* Frameworks without a dedicated adapter get a literal marker-wrapped
|
||||
* `<script src>` block written into the entry template named by
|
||||
* `.impeccable/live/config.json`. This module owns that block: building it,
|
||||
* inserting it at the configured anchor, removing it again, and the
|
||||
* Content-Security-Policy meta patch that keeps the cross-origin load allowed.
|
||||
*
|
||||
* It is deliberately framework-agnostic. Per-framework knowledge (Astro's
|
||||
* `is:inline`, for instance) arrives as the `scriptAttrs` argument, resolved
|
||||
* from the registry by the caller, so nothing here has to branch on a file
|
||||
* extension or a project shape.
|
||||
*/
|
||||
|
||||
import { buildLiveScriptSrc } from './script-src.mjs';
|
||||
|
||||
export const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
export const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/** Markers that identify a file as still carrying our tag-strategy patch. */
|
||||
export const TAG_PATCH_MARKERS = Object.freeze([MARKER_OPEN_TEXT, 'data-impeccable-csp-original']);
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
|
||||
|
||||
/**
|
||||
* `scriptAttrs` is a pre-rendered attribute string (trailing space included)
|
||||
* that the registry supplies for the target file. Astro is the only framework
|
||||
* that uses it today: Astro processes `<script>` tags by default and rewrites
|
||||
* src to its own bundled URL, so `is:inline ` opts out and the literal external
|
||||
* src survives.
|
||||
*/
|
||||
export function buildTagBlock(syntax, port, token, scriptAttrs = '') {
|
||||
const open = commentOpen(syntax);
|
||||
const close = commentClose(syntax);
|
||||
return (
|
||||
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
|
||||
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
|
||||
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function detectLineEnding(content) {
|
||||
if (content.includes('\r\n')) return '\r\n';
|
||||
if (content.includes('\r')) return '\r';
|
||||
return '\n';
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content, lineEnding) {
|
||||
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
|
||||
}
|
||||
|
||||
function readLineEndingAt(content, index) {
|
||||
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
|
||||
if (content[index] === '\n') return '\n';
|
||||
if (content[index] === '\r') return '\r';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function insertTag(content, config, port, token, scriptAttrs = '') {
|
||||
const lineEnding = detectLineEnding(content);
|
||||
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding);
|
||||
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
|
||||
// belong at the end, and the same literal can appear earlier in code blocks
|
||||
// within rendered documentation pages.
|
||||
if (config.insertBefore) {
|
||||
const idx = content.lastIndexOf(config.insertBefore);
|
||||
if (idx === -1) return content;
|
||||
return content.slice(0, idx) + block + content.slice(idx);
|
||||
}
|
||||
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
|
||||
// `<body>` open near the top of the document.
|
||||
const idx = content.indexOf(config.insertAfter);
|
||||
if (idx === -1) return content;
|
||||
const after = idx + config.insertAfter.length;
|
||||
// Preserve an existing trailing newline if the anchor already has one.
|
||||
// Slice the remainder from the original anchor offset, not prefix.length:
|
||||
// in the no-newline case prefix is one char longer than the anchor (the
|
||||
// appended '\n'), so slicing by prefix.length would drop the first real
|
||||
// character after the anchor (#227).
|
||||
const existingNewline = readLineEndingAt(content, after);
|
||||
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
|
||||
const rest = content.slice(after + existingNewline.length);
|
||||
return prefix + block + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the live script block. Matches either HTML or JSX comment markers
|
||||
* regardless of config (so stale tags from a wrong config can still be cleaned).
|
||||
*
|
||||
* Indent-preserving: captures any whitespace immediately preceding the opener
|
||||
* marker and re-emits it in place of the removed block. `insertTag` inserted
|
||||
* the block *after* the original line's indent and *before* the anchor (e.g.
|
||||
* `</body>`), which moved the indent onto the opener line and left the anchor
|
||||
* unindented. Replacing the whole block (plus its trailing newline) with just
|
||||
* the captured indent hands the indent back to the anchor that follows.
|
||||
*/
|
||||
export function removeTag(content, _syntax) {
|
||||
const patterns = [
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
let changed = false;
|
||||
let next = content;
|
||||
do {
|
||||
content = next;
|
||||
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
|
||||
if (/[\r\n]/.test(trailing)) return leadingIndent;
|
||||
return leadingIndent || trailing || '';
|
||||
});
|
||||
if (next !== content) changed = true;
|
||||
} while (next !== content);
|
||||
if (changed) return next;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-Security-Policy meta-tag patcher
|
||||
//
|
||||
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
|
||||
// the cross-origin load of /live.js (and the SSE/POST connection back to
|
||||
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
|
||||
//
|
||||
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
|
||||
// and stash the original `content` value in a `data-impeccable-csp-original`
|
||||
// attribute (base64) so revert is exact.
|
||||
//
|
||||
// On remove: detect the marker attribute, decode it, restore the original
|
||||
// content value verbatim, drop the marker.
|
||||
//
|
||||
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
|
||||
// shared helpers) is NOT patched here — those need framework-specific config
|
||||
// edits and are handled via the existing detect-csp.mjs reference output.
|
||||
// Only the in-source meta-tag form gets the auto-patch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
|
||||
|
||||
function findCspMetaTags(content) {
|
||||
const out = [];
|
||||
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
|
||||
let m;
|
||||
while ((m = tagRe.exec(content)) !== null) {
|
||||
const attrs = m[1];
|
||||
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
|
||||
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getAttr(attrs, name) {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
|
||||
const m = attrs.match(re);
|
||||
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
|
||||
}
|
||||
|
||||
function appendOriginToDirective(csp, directive, origin) {
|
||||
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
|
||||
const m = csp.match(re);
|
||||
if (m) {
|
||||
const tokens = m[4].trim().split(/\s+/);
|
||||
if (tokens.includes(origin)) return csp;
|
||||
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
|
||||
}
|
||||
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
|
||||
// narrow the policy compared to the default-src fallback (most users with
|
||||
// an explicit CSP have 'self' there).
|
||||
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
|
||||
}
|
||||
|
||||
export function patchCspMeta(content, port) {
|
||||
const tags = findCspMetaTags(content);
|
||||
if (tags.length === 0) return content;
|
||||
const origin = `http://localhost:${port}`;
|
||||
|
||||
// Walk last-to-first so prior splices don't invalidate later indices.
|
||||
let result = content;
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
const attrs = tag.attrs;
|
||||
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
|
||||
const contentAttr = getAttr(attrs, 'content');
|
||||
if (!contentAttr) continue;
|
||||
|
||||
const original = contentAttr.value;
|
||||
let patched = original;
|
||||
patched = appendOriginToDirective(patched, 'script-src', origin);
|
||||
patched = appendOriginToDirective(patched, 'connect-src', origin);
|
||||
// The shader overlay during 'generating' creates a screenshot via
|
||||
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
|
||||
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
|
||||
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
|
||||
if (patched === original) continue;
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function revertCspMeta(content) {
|
||||
const tags = findCspMetaTags(content);
|
||||
if (tags.length === 0) return content;
|
||||
|
||||
let result = content;
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
|
||||
if (!origAttr) continue;
|
||||
const contentAttr = getAttr(tag.attrs, 'content');
|
||||
if (!contentAttr) continue;
|
||||
|
||||
let originalValue;
|
||||
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
|
||||
catch { continue; }
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
|
||||
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
|
||||
// Drop the marker attribute and any single space immediately preceding it.
|
||||
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
|
||||
const newTag = tag.full.replace(tag.attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
|
||||
export function unpatchTagFile(content) {
|
||||
return revertCspMeta(removeTag(content));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* TanStack Start registry entry.
|
||||
*
|
||||
* Detection and the apply/remove pair are the existing adapter's
|
||||
* (`../tanstack-adapter.mjs`); this file only declares them to the registry
|
||||
* and names the artifacts the journal has to be able to heal.
|
||||
*/
|
||||
|
||||
import {
|
||||
TANSTACK_MARKER_OPEN,
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
unpatchTanStackRoot,
|
||||
} from '../tanstack-adapter.mjs';
|
||||
|
||||
export const tanstackStart = {
|
||||
name: 'tanstack-start',
|
||||
|
||||
detect(cwd) {
|
||||
return detectTanStackStartProject(cwd);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, project }) {
|
||||
return applyTanStackLiveAdapter({ cwd, port, token, project });
|
||||
},
|
||||
|
||||
remove({ cwd, project }) {
|
||||
return removeTanStackLiveAdapter({ cwd, project });
|
||||
},
|
||||
|
||||
// The mount component's extension follows the root route's, so the path
|
||||
// cannot live in the static ignore list.
|
||||
ignorePatterns(project) {
|
||||
return project?.componentFile ? [project.componentFile] : [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
if (!project) return [];
|
||||
return [
|
||||
{
|
||||
kind: 'created',
|
||||
path: project.componentFile,
|
||||
marker: 'impeccable-live-tanstack',
|
||||
pruneTo: 'src',
|
||||
},
|
||||
{
|
||||
kind: 'patched',
|
||||
path: project.rootRoute,
|
||||
patch: 'tanstack-root',
|
||||
markers: [TANSTACK_MARKER_OPEN],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
unpatch: {
|
||||
'tanstack-root': unpatchTanStackRoot,
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Generic Vite registry entry: a bundled app with a real `index.html` entry
|
||||
* and no framework-specific document ownership. React, Vue, Solid, Preact and
|
||||
* a plain TanStack Router SPA all land here — the marker-wrapped script block
|
||||
* goes straight into the HTML entry.
|
||||
*
|
||||
* This is the entry that catches everything with a bundler config; only
|
||||
* static-html sits below it.
|
||||
*/
|
||||
|
||||
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
|
||||
|
||||
const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectViteProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, VITE_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' };
|
||||
// A zero-config Vite app is index.html + package.json, the same pair
|
||||
// roots.mjs treats as an app root.
|
||||
if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) {
|
||||
return { configFile: null, via: 'zero-config' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const viteGeneric = {
|
||||
name: 'vite-generic',
|
||||
|
||||
detect(cwd) {
|
||||
return detectViteProject(cwd);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Live root resolution: the single place that decides which directories a live
|
||||
* session operates on. Every live entry script resolves this once at startup
|
||||
* (see enterLiveRoot) instead of trusting its ambient cwd, which is how a
|
||||
* `cd` used to silently fork the whole system into a second, empty project.
|
||||
*
|
||||
* Four distinct roots travel together as one manifest:
|
||||
*
|
||||
* appRoot what the dev server serves; where live session state,
|
||||
* injected adapters, and preview modules live.
|
||||
* repoRoot the git boundary (falls back to appRoot outside git).
|
||||
* contextRoot the nearest directory from appRoot up to repoRoot carrying
|
||||
* PRODUCT.md / DESIGN.md (canonical spot or a fallback dir).
|
||||
* sessionRoot <appRoot>/.impeccable/live — durable live state.
|
||||
*
|
||||
* appRoot detection keys on dev-server config presence (vite/svelte/next/
|
||||
* astro/nuxt/... config files), not on monorepo brand markers. A nested
|
||||
* website/ with vite.config.js wins over a repo root that merely has a
|
||||
* package.json. Workspace declarations are one input, not the gatekeeper.
|
||||
*
|
||||
* The resolved manifest is persisted at <appRoot>/.impeccable/live/roots.json
|
||||
* plus a pointer at <repoRoot>/.impeccable/live/app-root.json when the two
|
||||
* differ, so a helper invoked from anywhere inside the repo finds the same
|
||||
* roots the boot decided on. When several apps in one repo run live, the
|
||||
* pointer follows the most recent boot; per-app roots.json files stay put.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
|
||||
const ROOTS_MANIFEST_VERSION = 1;
|
||||
const ROOTS_FILE = 'roots.json';
|
||||
const POINTER_FILE = 'app-root.json';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
const CONTEXT_FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
// Presence of any of these marks a directory as a dev-served app root.
|
||||
const DEV_CONFIG_MARKERS = [
|
||||
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs',
|
||||
'svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts',
|
||||
'next.config.js', 'next.config.mjs', 'next.config.ts',
|
||||
'astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.cjs',
|
||||
'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs',
|
||||
'remix.config.js', 'react-router.config.ts',
|
||||
'angular.json',
|
||||
'webpack.config.js', 'webpack.config.ts',
|
||||
];
|
||||
|
||||
const CANDIDATE_SCAN_IGNORED = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', 'coverage', 'vendor', 'vendors',
|
||||
'.next', '.nuxt', '.svelte-kit', '.astro', '.turbo', '.cache', '.vercel',
|
||||
]);
|
||||
const CANDIDATE_SCAN_DEPTH = 2;
|
||||
|
||||
function exists(p) {
|
||||
try { fs.statSync(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function isDir(p) {
|
||||
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
if (exists(abs)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasDevConfig(dir) {
|
||||
if (DEV_CONFIG_MARKERS.some((name) => exists(path.join(dir, name)))) return true;
|
||||
// A plain Vite app can run with zero config: index.html + package.json.
|
||||
return exists(path.join(dir, 'index.html')) && exists(path.join(dir, 'package.json'));
|
||||
}
|
||||
|
||||
function isAppRoot(dir) {
|
||||
// A directory already configured for live IS an app root, dev config or not
|
||||
// (plain static multi-page projects have no bundler config).
|
||||
return hasDevConfig(dir) || exists(path.join(dir, '.impeccable', 'live', 'config.json'));
|
||||
}
|
||||
|
||||
function findContextFile(dir, names) {
|
||||
const direct = firstExisting(dir, names);
|
||||
if (direct) return direct;
|
||||
for (const rel of CONTEXT_FALLBACK_DIRS) {
|
||||
const nested = firstExisting(path.join(dir, rel), names);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findGitRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === home) return null;
|
||||
if (exists(path.join(dir, '.git'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function walkUp(startDir, upperBound, visit) {
|
||||
let dir = path.resolve(startDir);
|
||||
const stop = path.resolve(upperBound);
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === home) return null;
|
||||
const hit = visit(dir);
|
||||
if (hit) return hit;
|
||||
if (dir === stop) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function insideOrEqual(candidate, root) {
|
||||
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan downward (bounded depth) for directories carrying a dev-server config.
|
||||
* Used when live boots from a directory that is not itself an app root and no
|
||||
* --target narrows the choice: one candidate is auto-picked, several become a
|
||||
* selection prompt.
|
||||
*/
|
||||
export function discoverAppCandidates(rootDir, depth = CANDIDATE_SCAN_DEPTH) {
|
||||
const found = [];
|
||||
const scan = (dir, remaining) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.') || CANDIDATE_SCAN_IGNORED.has(entry.name)) continue;
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (hasDevConfig(abs)) {
|
||||
found.push(abs);
|
||||
continue; // nested apps below an app root are that app's business
|
||||
}
|
||||
if (remaining > 1) scan(abs, remaining - 1);
|
||||
}
|
||||
};
|
||||
scan(path.resolve(rootDir), depth);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh root resolution. Never reads a persisted manifest.
|
||||
*
|
||||
* Returns { manifest } on success or { selection } when several candidate
|
||||
* apps exist and nothing disambiguates.
|
||||
*/
|
||||
export function resolveRoots({ cwd = process.cwd(), targetPath = null } = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const absTarget = targetPath
|
||||
? (path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath))
|
||||
: null;
|
||||
const targetDir = absTarget
|
||||
? (isDir(absTarget) ? absTarget : path.dirname(absTarget))
|
||||
: absCwd;
|
||||
|
||||
// The walk bound must be an ancestor of the target: a git root found from
|
||||
// the CWD is only usable when the target actually lives inside it,
|
||||
// otherwise the walk would climb out of both trees.
|
||||
const targetGitRoot = findGitRoot(targetDir);
|
||||
const cwdGitRoot = targetGitRoot ? null : findGitRoot(absCwd);
|
||||
const repoRoot = targetGitRoot
|
||||
|| (cwdGitRoot && insideOrEqual(targetDir, cwdGitRoot) ? cwdGitRoot : null);
|
||||
// Without a git boundary, never ascend above the starting directory: the
|
||||
// filesystem above an unversioned project is not ours to interpret.
|
||||
const upperBound = repoRoot || targetDir;
|
||||
|
||||
// The workspace-aware legacy resolution (context.mjs) still decides two
|
||||
// things: the fallback when no app marker exists, and how far the marker
|
||||
// walk may ascend when an explicit target selected a workspace child. A
|
||||
// root-level live config must never shadow a child the target picked.
|
||||
const legacyRoot = resolveProjectRoot(absCwd, absTarget ? { targetPath: absTarget } : {});
|
||||
const markerBound = absTarget && insideOrEqual(targetDir, legacyRoot) && insideOrEqual(legacyRoot, upperBound)
|
||||
? legacyRoot
|
||||
: upperBound;
|
||||
|
||||
let appRoot = walkUp(targetDir, markerBound, (dir) => (isAppRoot(dir) ? dir : null));
|
||||
let resolvedFrom = appRoot
|
||||
? (absTarget ? `target:${path.relative(absCwd, absTarget) || '.'}` : 'cwd')
|
||||
: null;
|
||||
|
||||
if (!appRoot && !absTarget) {
|
||||
const candidates = discoverAppCandidates(absCwd);
|
||||
if (candidates.length === 1) {
|
||||
appRoot = candidates[0];
|
||||
resolvedFrom = `candidate:${path.relative(absCwd, appRoot)}`;
|
||||
} else if (candidates.length > 1) {
|
||||
return {
|
||||
selection: {
|
||||
candidates: candidates.map((abs) => ({
|
||||
name: path.basename(abs),
|
||||
path: path.relative(absCwd, abs).split(path.sep).join('/'),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
// No app marker anywhere: defer to the workspace-aware legacy resolution
|
||||
// (workspace child for a targeted monorepo path, cwd otherwise). Never
|
||||
// adopt an arbitrary ancestor just because it has a package.json, and
|
||||
// never adopt a root that does not even contain the target.
|
||||
appRoot = insideOrEqual(targetDir, legacyRoot) ? legacyRoot : targetDir;
|
||||
resolvedFrom = 'fallback';
|
||||
}
|
||||
|
||||
const effectiveRepoRoot = repoRoot && insideOrEqual(appRoot, repoRoot) ? repoRoot : appRoot;
|
||||
|
||||
// Each context file resolves independently: a child app may carry its own
|
||||
// PRODUCT.md while inheriting DESIGN.md from the repo root (or vice versa).
|
||||
const productPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, PRODUCT_NAMES));
|
||||
const designPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, DESIGN_NAMES));
|
||||
const contextRoot = productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: null;
|
||||
|
||||
return {
|
||||
manifest: {
|
||||
version: ROOTS_MANIFEST_VERSION,
|
||||
appRoot,
|
||||
repoRoot: effectiveRepoRoot,
|
||||
contextRoot,
|
||||
sessionRoot: path.join(appRoot, '.impeccable', 'live'),
|
||||
productPath,
|
||||
designPath,
|
||||
resolvedFrom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rootsFilePath(appRoot) {
|
||||
return path.join(appRoot, '.impeccable', 'live', ROOTS_FILE);
|
||||
}
|
||||
|
||||
function pointerFilePath(repoRoot) {
|
||||
return path.join(repoRoot, '.impeccable', 'live', POINTER_FILE);
|
||||
}
|
||||
|
||||
export function writeRootsManifest(manifest) {
|
||||
const file = rootsFilePath(manifest.appRoot);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
|
||||
if (path.resolve(manifest.repoRoot) !== path.resolve(manifest.appRoot)) {
|
||||
const pointer = pointerFilePath(manifest.repoRoot);
|
||||
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
||||
fs.writeFileSync(pointer, JSON.stringify({ appRoot: manifest.appRoot }));
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function readManifestAt(appRoot) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(rootsFilePath(appRoot), 'utf-8'));
|
||||
if (!raw || typeof raw.appRoot !== 'string') return null;
|
||||
// A manifest is only trusted where it claims to live; anything else is a
|
||||
// copied or stale file.
|
||||
if (path.resolve(raw.appRoot) !== path.resolve(appRoot)) return null;
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the roots for the live session governing `cwd`, preferring a
|
||||
* persisted manifest (written by the boot) over fresh detection:
|
||||
*
|
||||
* 1. Walk up from cwd looking for .impeccable/live/roots.json.
|
||||
* 2. At the git root, follow .impeccable/live/app-root.json to the app.
|
||||
* 3. Fresh resolveRoots().
|
||||
*
|
||||
* Fresh results are NOT persisted here; only the boot (live.mjs / server
|
||||
* startup) writes manifests, so ad-hoc helper invocations cannot mint
|
||||
* conflicting truth.
|
||||
*/
|
||||
export function resolveLiveRoots(cwd = process.cwd(), { targetPath = null } = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
|
||||
if (!targetPath) {
|
||||
const persisted = walkUp(absCwd, findGitRoot(absCwd) || absCwd, (dir) => readManifestAt(dir));
|
||||
if (persisted) return { manifest: persisted, source: 'persisted' };
|
||||
|
||||
const gitRoot = findGitRoot(absCwd);
|
||||
if (gitRoot) {
|
||||
try {
|
||||
const pointer = JSON.parse(fs.readFileSync(pointerFilePath(gitRoot), 'utf-8'));
|
||||
if (pointer && typeof pointer.appRoot === 'string') {
|
||||
const viaPointer = readManifestAt(pointer.appRoot);
|
||||
if (viaPointer) return { manifest: viaPointer, source: 'pointer' };
|
||||
}
|
||||
} catch { /* no pointer */ }
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = resolveRoots({ cwd: absCwd, targetPath });
|
||||
if (fresh.selection) return { selection: fresh.selection, source: 'fresh' };
|
||||
return { manifest: fresh.manifest, source: 'fresh' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-point guard for live CLI scripts: resolve the governing roots and
|
||||
* make appRoot the process cwd so every downstream path derivation agrees
|
||||
* with the boot. Returns the manifest. Never throws; on selection ambiguity
|
||||
* it stays in the current directory (the boot flow handles prompting).
|
||||
*/
|
||||
export function enterLiveRoot(cwd = process.cwd()) {
|
||||
const resolved = resolveLiveRoots(cwd);
|
||||
if (!resolved.manifest) return null;
|
||||
const appRoot = resolved.manifest.appRoot;
|
||||
if (path.resolve(cwd) !== path.resolve(appRoot) && isDir(appRoot)) {
|
||||
try { process.chdir(appRoot); } catch { /* keep current cwd */ }
|
||||
}
|
||||
return resolved.manifest;
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
|
||||
import { COMPLETED_SESSION_PHASES, GENERATION_FENCED_SESSION_PHASES } from './vocabulary.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
export const GENERATION_FENCED_PHASES = new Set([
|
||||
'accept_requested',
|
||||
'discard_requested',
|
||||
'carbonize_required',
|
||||
'completed',
|
||||
'discarded',
|
||||
]);
|
||||
const COMPLETED_PHASES = new Set(COMPLETED_SESSION_PHASES);
|
||||
export const GENERATION_FENCED_PHASES = new Set(GENERATION_FENCED_SESSION_PHASES);
|
||||
|
||||
// The snapshot file carries two bookkeeping fields the snapshot itself does not
|
||||
// own: how large the journal was when the snapshot was written, and the next
|
||||
// sequence number. Both are stripped before a snapshot is handed to a caller.
|
||||
// The byte count is what makes a cached snapshot verifiable — the journal is
|
||||
// append-only, so a matching size means no event has landed since.
|
||||
const META_JOURNAL_BYTES = '__journalBytes';
|
||||
const META_NEXT_SEQ = '__nextSeq';
|
||||
|
||||
// TODO(revision-unification): `checkpointRevision`, `browserCheckpointRevision`,
|
||||
// and `publicationCheckpointRevision` are three counters for two domains.
|
||||
// `checkpointRevision` is a compatibility mirror of the browser counter kept for
|
||||
// older readers. Collapsing them means changing what a resumed browser compares
|
||||
// its local revision against, so it belongs in a pass that owns resume ordering,
|
||||
// not in a caching change.
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
|
||||
// No snapshot cache on purpose: appendEvent and getSnapshot both rebuild from
|
||||
// the journal so sequence numbers and phase fences never come from a stale
|
||||
// in-memory copy when the publisher/complete helpers append from another
|
||||
// process. A cache written but never read would grow per session for the
|
||||
// lifetime of the server without ever saving a rebuild.
|
||||
// Derived state per session, keyed by what the journal looked like when it was
|
||||
// derived. Publisher/complete helpers append from other processes, so the key
|
||||
// is the journal's own (path, size, mtime) rather than a trusted local write
|
||||
// count: an append this process did not make invalidates the entry and the
|
||||
// next read replays. Without the cache every append and every read replayed
|
||||
// the whole journal, which made a long session quadratic in its own length.
|
||||
/** @type {Map<string, { snapshot: object, nextSeq: number, journalPath: string, size: number, mtimeMs: number }>} */
|
||||
const derived = new Map();
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
@@ -29,42 +43,104 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
return primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current derived state for a session, from the in-memory cache when the
|
||||
* journal has not moved, from the snapshot file when that file is provably
|
||||
* current, and from a full replay otherwise.
|
||||
*/
|
||||
function readState(id, { allowSnapshotFile = true } = {}) {
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const stat = statOrNull(journalPath);
|
||||
const size = stat ? stat.size : -1;
|
||||
const mtimeMs = stat ? stat.mtimeMs : -1;
|
||||
|
||||
const cached = derived.get(id);
|
||||
if (cached && cached.journalPath === journalPath && cached.size === size && cached.mtimeMs === mtimeMs) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (allowSnapshotFile && stat) {
|
||||
const hydrated = readSnapshotFile(getSnapshotPath(rootDir, id), id, size);
|
||||
if (hydrated) {
|
||||
const entry = { ...hydrated, journalPath, size, mtimeMs };
|
||||
derived.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
const entry = { snapshot: rebuilt.snapshot, nextSeq: rebuilt.nextSeq, journalPath, size, mtimeMs };
|
||||
derived.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function persist(id, snapshot, nextSeq) {
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const stat = statOrNull(journalPath);
|
||||
writeSnapshot(snapshotPath, snapshot, { journalBytes: stat ? stat.size : -1, nextSeq });
|
||||
derived.set(id, {
|
||||
snapshot,
|
||||
nextSeq,
|
||||
journalPath,
|
||||
size: stat ? stat.size : -1,
|
||||
mtimeMs: stat ? stat.mtimeMs : -1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
// The readable path just moved from legacy to primary; anything derived
|
||||
// against the old path describes a file this session no longer reads.
|
||||
derived.delete(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;
|
||||
// Reuse the derived state when the journal has not changed under us, and
|
||||
// apply the new event on top of it. Correctness still comes from the
|
||||
// journal: any append from another process invalidates the entry above
|
||||
// and this replays before writing, so sequence numbers and phase fences
|
||||
// are never taken from a stale copy.
|
||||
const prior = readState(normalized.id);
|
||||
const entry = {
|
||||
seq,
|
||||
seq: prior.nextSeq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
writeSnapshot(snapshotPath, next);
|
||||
const next = applyEvent(prior.snapshot, entry);
|
||||
persist(normalized.id, next, prior.nextSeq + 1);
|
||||
return next;
|
||||
},
|
||||
/**
|
||||
* Read-only. `live-status` and `live-resume` call this against a session a
|
||||
* running server owns; writing the snapshot file here made every read a
|
||||
* write and let a reader's replay of a half-written journal land on disk.
|
||||
* Snapshot files are written by appendEvent and by flush().
|
||||
*/
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
const { snapshot } = readState(id);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(snapshot.phase)) return null;
|
||||
return snapshot;
|
||||
},
|
||||
/**
|
||||
* Write the snapshot file for a session without appending an event. The
|
||||
* durable truth is the journal, so this only refreshes the read cache other
|
||||
* processes use; callers that need the state itself should use getSnapshot.
|
||||
*/
|
||||
flush(id = sessionId) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const state = readState(id, { allowSnapshotFile: false });
|
||||
persist(id, state.snapshot, state.nextSeq);
|
||||
return state.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
@@ -74,6 +150,9 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
// Each id goes through readState, so a session whose journal has not moved
|
||||
// since it was last derived costs a stat and nothing more. The server calls
|
||||
// this on every /status and on every SSE connect.
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
@@ -82,6 +161,39 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
|
||||
};
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate derived state from a snapshot file, but only when it provably
|
||||
* describes the journal as it stands right now. Anything short of an exact byte
|
||||
* match on an append-only file means events landed after the snapshot was
|
||||
* written, and the caller replays instead.
|
||||
*/
|
||||
function readSnapshotFile(snapshotPath, id, journalBytes) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
if (parsed[META_JOURNAL_BYTES] !== journalBytes) return null;
|
||||
if (!Number.isInteger(parsed[META_NEXT_SEQ])) return null;
|
||||
const nextSeq = parsed[META_NEXT_SEQ];
|
||||
delete parsed[META_JOURNAL_BYTES];
|
||||
delete parsed[META_NEXT_SEQ];
|
||||
// The journal owns identity; a snapshot file copied between session ids is
|
||||
// not a reason to answer with the wrong id.
|
||||
if (parsed.id !== id) return null;
|
||||
return { snapshot: { ...baseSnapshot(id), ...parsed }, nextSeq };
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
@@ -127,11 +239,37 @@ function baseSnapshot(id) {
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
annotationArtifacts: [],
|
||||
// Render truth. `arrivedVariants` says what the agent published; these say
|
||||
// what the browser actually got on screen. They are kept alongside the
|
||||
// published counters rather than replacing them so older readers keep
|
||||
// working, but they are the only fields that answer "did the user ever see
|
||||
// a variant".
|
||||
mountedVariants: [],
|
||||
mountFailures: [],
|
||||
renderState: null,
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// How many mount failures a session keeps. The card in the browser shows the
|
||||
// newest one; the agent needs enough history to spot a variant that fails
|
||||
// every republish, not the whole retry storm.
|
||||
const MOUNT_FAILURE_HISTORY = 5;
|
||||
|
||||
/**
|
||||
* `pending` = the agent published and nothing has acked yet, `mounted` = at
|
||||
* least one variant reached the DOM, `failed` = the browser reported failures
|
||||
* and nothing ever mounted. A single success outranks any number of failures:
|
||||
* the user is looking at something.
|
||||
*/
|
||||
function deriveRenderState(snapshot) {
|
||||
if (snapshot.mountedVariants.length > 0) return 'mounted';
|
||||
if (snapshot.mountFailures.length > 0) return 'failed';
|
||||
if (snapshot.generationCompletedAt) return 'pending';
|
||||
return null;
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
@@ -159,7 +297,7 @@ function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
function applyEvent(snapshot, entry) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
@@ -168,14 +306,13 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
mountedVariants: [...(snapshot.mountedVariants || [])],
|
||||
mountFailures: [...(snapshot.mountFailures || [])],
|
||||
renderState: snapshot.renderState ?? null,
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
@@ -184,6 +321,11 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
next.variantPlan = null;
|
||||
// A new cycle publishes new files: everything the browser told us about
|
||||
// the previous batch is now about modules that no longer exist.
|
||||
next.mountedVariants = [];
|
||||
next.mountFailures = [];
|
||||
next.renderState = null;
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variant_plan':
|
||||
@@ -238,7 +380,38 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
next.renderState = deriveRenderState(next);
|
||||
break;
|
||||
case 'variant_mounted': {
|
||||
const variant = Number(event.variant);
|
||||
if (!Number.isInteger(variant) || variant < 1) {
|
||||
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
|
||||
break;
|
||||
}
|
||||
if (!next.mountedVariants.includes(variant)) {
|
||||
next.mountedVariants = [...next.mountedVariants, variant].sort((a, b) => a - b);
|
||||
}
|
||||
next.renderState = deriveRenderState(next);
|
||||
break;
|
||||
}
|
||||
case 'variant_mount_failed': {
|
||||
const variant = Number(event.variant);
|
||||
if (!Number.isInteger(variant) || variant < 1) {
|
||||
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
|
||||
break;
|
||||
}
|
||||
next.mountFailures = [
|
||||
...next.mountFailures,
|
||||
{
|
||||
variant,
|
||||
url: typeof event.url === 'string' ? event.url : null,
|
||||
error: typeof event.error === 'string' ? event.error : null,
|
||||
at: event.at ?? (Date.parse(entry.ts || '') || Date.now()),
|
||||
},
|
||||
].slice(-MOUNT_FAILURE_HISTORY);
|
||||
next.renderState = deriveRenderState(next);
|
||||
break;
|
||||
}
|
||||
case 'checkpoint':
|
||||
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 });
|
||||
@@ -361,6 +534,11 @@ function upsertArtifact(artifacts, artifact) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
function writeSnapshot(snapshotPath, snapshot, meta) {
|
||||
const payload = {
|
||||
...snapshot,
|
||||
[META_JOURNAL_BYTES]: meta?.journalBytes ?? -1,
|
||||
[META_NEXT_SEQ]: meta?.nextSeq ?? 1,
|
||||
};
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(payload, null, 2) + '\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
/**
|
||||
* AST-based Svelte scaffolding for live component previews.
|
||||
*
|
||||
* The scaffolder turns the selected block of a route's markup into a detached
|
||||
* preview component whose dynamic values arrive as props. The old
|
||||
* implementation matched `{...}` with a regex, which flattened control-flow
|
||||
* blocks ({#each}, {#if}) into scalar text props and shipped structurally
|
||||
* wrong previews. This module uses the app's own svelte compiler
|
||||
* (parse with modern: true) and replaces only expressions that are FREE,
|
||||
* i.e. reference identifiers not bound by an enclosing template scope:
|
||||
*
|
||||
* {#each stages as stage, i} stages -> collection prop (array)
|
||||
* <span>{stage.label}</span> bound -> left verbatim
|
||||
* {/each}
|
||||
* <p>{footerNote}</p> free -> text prop (string)
|
||||
*
|
||||
* Constructs that cannot work in a detached component (component tags whose
|
||||
* imports live in the route file, bind:/use: directives, await blocks,
|
||||
* render tags) mark the analysis unsupported; the caller falls back to
|
||||
* source-preview mode, which keeps the markup inside the route file where
|
||||
* those references still resolve. A wrong preview is worse than a plain one.
|
||||
*
|
||||
* The compiler is resolved from the APP's node_modules, never bundled: the
|
||||
* preview must be parsed by the same svelte version that will compile it.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
const HANDLER_ATTR_RE = /^on[a-z]/;
|
||||
|
||||
/**
|
||||
* Resolve the app's svelte compiler synchronously (svelte 5 ships a CJS
|
||||
* compiler build, so createRequire works and the accept/scaffold pipeline
|
||||
* stays synchronous). Returns { parse, compile, VERSION } or null.
|
||||
*/
|
||||
export function loadSvelteCompiler(appRoot) {
|
||||
try {
|
||||
const req = createRequire(path.join(appRoot, 'package.json'));
|
||||
const mod = req('svelte/compiler');
|
||||
if (typeof mod.parse !== 'function') return null;
|
||||
const major = parseInt(String(mod.VERSION || '0'), 10);
|
||||
if (major < 5) return null; // detached mount() previews are svelte 5 only
|
||||
return { parse: mod.parse, compile: mod.compile, VERSION: mod.VERSION };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESTree helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collect the root identifiers an ESTree expression reads. Walks generically;
|
||||
* skips non-computed member properties and non-computed/non-shorthand object
|
||||
* keys, which are names, not references.
|
||||
*/
|
||||
export function collectRootIdentifiers(node, out = new Set()) {
|
||||
if (!node || typeof node !== 'object') return out;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) collectRootIdentifiers(item, out);
|
||||
return out;
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'Identifier':
|
||||
out.add(node.name);
|
||||
return out;
|
||||
case 'MemberExpression':
|
||||
collectRootIdentifiers(node.object, out);
|
||||
if (node.computed) collectRootIdentifiers(node.property, out);
|
||||
return out;
|
||||
case 'Property':
|
||||
if (node.computed) collectRootIdentifiers(node.key, out);
|
||||
collectRootIdentifiers(node.value, out);
|
||||
return out;
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionExpression': {
|
||||
// Params shadow outer names inside the body.
|
||||
const bound = new Set();
|
||||
for (const param of node.params || []) collectPatternNames(param, bound);
|
||||
const inner = collectRootIdentifiers(node.body, new Set());
|
||||
for (const name of inner) if (!bound.has(name)) out.add(name);
|
||||
return out;
|
||||
}
|
||||
default: {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
||||
collectRootIdentifiers(node[key], out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect names bound by a destructuring pattern (each contexts, const tags). */
|
||||
export function collectPatternNames(pattern, out = new Set()) {
|
||||
if (!pattern || typeof pattern !== 'object') return out;
|
||||
switch (pattern.type) {
|
||||
case 'Identifier':
|
||||
out.add(pattern.name);
|
||||
return out;
|
||||
case 'ObjectPattern':
|
||||
for (const prop of pattern.properties || []) {
|
||||
if (prop.type === 'RestElement') collectPatternNames(prop.argument, out);
|
||||
else collectPatternNames(prop.value, out);
|
||||
}
|
||||
return out;
|
||||
case 'ArrayPattern':
|
||||
for (const el of pattern.elements || []) if (el) collectPatternNames(el, out);
|
||||
return out;
|
||||
case 'AssignmentPattern':
|
||||
collectPatternNames(pattern.left, out);
|
||||
return out;
|
||||
case 'RestElement':
|
||||
collectPatternNames(pattern.argument, out);
|
||||
return out;
|
||||
default:
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Analysis {
|
||||
constructor(source) {
|
||||
this.source = source;
|
||||
this.replacements = []; // { start, end, prop } source ranges to swap
|
||||
this.contract = []; // [{ prop, expr, kind, ... }]
|
||||
this.byExpr = new Map(); // expr text -> contract entry
|
||||
this.usedNames = new Set();
|
||||
this.unsupported = null;
|
||||
}
|
||||
|
||||
fail(reason) {
|
||||
if (!this.unsupported) this.unsupported = reason;
|
||||
}
|
||||
|
||||
propFor(exprText, kind, extra = {}) {
|
||||
const existing = this.byExpr.get(exprText);
|
||||
if (existing) return existing;
|
||||
const base = derivePropName(exprText);
|
||||
let name = base;
|
||||
let n = 2;
|
||||
while (this.usedNames.has(name)) name = `${base}${n++}`;
|
||||
this.usedNames.add(name);
|
||||
const entry = { prop: name, expr: exprText, kind, ...extra };
|
||||
this.byExpr.set(exprText, entry);
|
||||
this.contract.push(entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
// A derived prop name lands in `let { <name> } = $props()`; a reserved word
|
||||
// there is a syntax error the session only hits at import time.
|
||||
const RESERVED_PROP_NAMES = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
|
||||
'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
|
||||
'finally', 'for', 'function', 'if', 'implements', 'import', 'in',
|
||||
'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private',
|
||||
'protected', 'public', 'return', 'static', 'super', 'switch', 'this',
|
||||
'throw', 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while',
|
||||
'with', 'yield',
|
||||
]);
|
||||
|
||||
export function derivePropName(expr) {
|
||||
const tail = String(expr).match(/(?:\.|\[["']?)([A-Za-z_$][\w$]*)["']?\]?\s*$/);
|
||||
const candidate = (tail && tail[1])
|
||||
|| (String(expr).match(/^([A-Za-z_$][\w$]*)$/) || [])[1]
|
||||
|| 'value';
|
||||
return RESERVED_PROP_NAMES.has(candidate) ? `${candidate}Value` : candidate;
|
||||
}
|
||||
|
||||
function exprText(source, node) {
|
||||
return source.slice(node.start, node.end);
|
||||
}
|
||||
|
||||
function isFree(node, scopes) {
|
||||
const roots = collectRootIdentifiers(node);
|
||||
if (roots.size === 0) return false; // literal-only: nothing to hydrate
|
||||
for (const name of roots) {
|
||||
for (const scope of scopes) {
|
||||
if (scope.has(name)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a parsed template fragment. `scopes` is a stack of Sets of bound
|
||||
* names; the outermost call passes an empty stack.
|
||||
*/
|
||||
function analyzeFragment(fragment, analysis, scopes) {
|
||||
if (!fragment || !Array.isArray(fragment.nodes)) return;
|
||||
// ConstTag declarations bind for the whole fragment.
|
||||
const fragmentScope = new Set();
|
||||
const nextScopes = [...scopes, fragmentScope];
|
||||
for (const node of fragment.nodes) {
|
||||
if (node.type === 'ConstTag' && node.declaration) {
|
||||
for (const decl of node.declaration.declarations || []) {
|
||||
collectPatternNames(decl.id, fragmentScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of fragment.nodes) analyzeNode(node, analysis, nextScopes);
|
||||
}
|
||||
|
||||
function analyzeNode(node, analysis, scopes) {
|
||||
if (!node || analysis.unsupported) return;
|
||||
switch (node.type) {
|
||||
case 'Text':
|
||||
case 'Comment':
|
||||
return;
|
||||
case 'ExpressionTag': {
|
||||
if (isFree(node.expression, scopes)) {
|
||||
const text = exprText(analysis.source, node.expression);
|
||||
const entry = analysis.propFor(text, 'text');
|
||||
// node.start/end include the braces; keep them, swap the inside.
|
||||
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'HtmlTag': {
|
||||
if (isFree(node.expression, scopes)) {
|
||||
const text = exprText(analysis.source, node.expression);
|
||||
const entry = analysis.propFor(text, 'raw');
|
||||
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'ConstTag': {
|
||||
// Its expression may read free names; leave them: the declaration
|
||||
// travels with the markup and stays valid only if its inputs do.
|
||||
if (node.declaration) {
|
||||
for (const decl of node.declaration.declarations || []) {
|
||||
if (decl.init && isFree(decl.init, scopes)) {
|
||||
const text = exprText(analysis.source, decl.init);
|
||||
const entry = analysis.propFor(text, 'text');
|
||||
analysis.replacements.push({ start: decl.init.start, end: decl.init.end, prop: entry.prop });
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'EachBlock': {
|
||||
if (isFree(node.expression, scopes)) {
|
||||
const text = exprText(analysis.source, node.expression);
|
||||
const item = describeEachItem(node, analysis.source);
|
||||
// Keyed each: the key must evaluate to a distinct value per hydrated
|
||||
// item or Svelte throws each_key_duplicate at mount. A key that is a
|
||||
// plain member of the item (the common `(item.id)` shape) gets a
|
||||
// synthetic per-index value injected by the browser (keyField).
|
||||
// Anything else cannot be hydrated safely; source-preview mode keeps
|
||||
// it correct.
|
||||
if (node.key) {
|
||||
const keyInfo = classifyEachKey(node);
|
||||
if (keyInfo.unsupported) {
|
||||
analysis.fail(keyInfo.unsupported);
|
||||
return;
|
||||
}
|
||||
if (keyInfo.keyField) {
|
||||
if (item.textSlots.some((slot) => slot.key === keyInfo.keyField)) {
|
||||
// The key doubles as a displayed slot; a synthetic value would
|
||||
// change visible text, and the displayed text may not be
|
||||
// unique. Not previewable in a detached component.
|
||||
analysis.fail('each key that is also a displayed field requires source-preview mode');
|
||||
return;
|
||||
}
|
||||
item.keyField = keyInfo.keyField;
|
||||
}
|
||||
}
|
||||
const entry = analysis.propFor(text, 'collection', { item });
|
||||
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
||||
}
|
||||
const bound = new Set();
|
||||
if (node.context) collectPatternNames(node.context, bound);
|
||||
if (node.index) bound.add(node.index);
|
||||
analyzeFragment(node.body, analysis, [...scopes, bound]);
|
||||
if (node.fallback) analyzeFragment(node.fallback, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
case 'IfBlock': {
|
||||
if (isFree(node.test, scopes)) {
|
||||
const text = exprText(analysis.source, node.test);
|
||||
// The browser hydrates a free condition from what the live page
|
||||
// currently shows: when the consequent's root element is present
|
||||
// under the picked element, the condition is on.
|
||||
const entry = analysis.propFor(text, 'condition', {
|
||||
probe: describeElementProbe(node.consequent),
|
||||
});
|
||||
analysis.replacements.push({ start: node.test.start, end: node.test.end, prop: entry.prop });
|
||||
}
|
||||
analyzeFragment(node.consequent, analysis, scopes);
|
||||
if (node.alternate) analyzeFragment(node.alternate, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
case 'KeyBlock': {
|
||||
if (isFree(node.expression, scopes)) {
|
||||
const text = exprText(analysis.source, node.expression);
|
||||
const entry = analysis.propFor(text, 'text');
|
||||
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
|
||||
}
|
||||
analyzeFragment(node.fragment, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
case 'SnippetBlock': {
|
||||
const bound = new Set();
|
||||
for (const param of node.parameters || []) collectPatternNames(param, bound);
|
||||
// The snippet's own name becomes available to render tags in this file.
|
||||
analyzeFragment(node.body, analysis, [...scopes, bound]);
|
||||
return;
|
||||
}
|
||||
case 'RegularElement':
|
||||
case 'SlotElement':
|
||||
case 'TitleElement': {
|
||||
if (node.name === 'script') {
|
||||
// An inline script inside the selected block carries route-scoped
|
||||
// code; running it a second time from a detached preview is wrong.
|
||||
analysis.fail('inline script element requires source-preview mode');
|
||||
return;
|
||||
}
|
||||
analyzeAttributes(node, analysis, scopes);
|
||||
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
case 'SvelteElement':
|
||||
case 'SvelteFragment':
|
||||
case 'SvelteBoundary': {
|
||||
analyzeAttributes(node, analysis, scopes);
|
||||
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
case 'Component':
|
||||
case 'SvelteComponent':
|
||||
case 'SvelteSelf':
|
||||
// The component's import lives in the route file; a detached preview
|
||||
// cannot resolve it. Source-preview mode keeps it working.
|
||||
analysis.fail(`component tag <${node.name || 'Component'}> requires source-preview mode`);
|
||||
return;
|
||||
case 'RenderTag':
|
||||
analysis.fail('render tag requires source-preview mode');
|
||||
return;
|
||||
case 'AwaitBlock':
|
||||
analysis.fail('await block requires source-preview mode');
|
||||
return;
|
||||
case 'SvelteHead':
|
||||
case 'SvelteWindow':
|
||||
case 'SvelteDocument':
|
||||
case 'SvelteBody':
|
||||
analysis.fail(`${node.type} requires source-preview mode`);
|
||||
return;
|
||||
default: {
|
||||
if (node.fragment) analyzeFragment(node.fragment, analysis, scopes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeAttributes(node, analysis, scopes) {
|
||||
for (const attr of node.attributes || []) {
|
||||
switch (attr.type) {
|
||||
case 'Attribute': {
|
||||
if (attr.value === true) break;
|
||||
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
|
||||
for (const part of parts) {
|
||||
if (!part || part.type !== 'ExpressionTag') continue;
|
||||
if (!isFree(part.expression, scopes)) continue;
|
||||
const text = exprText(analysis.source, part.expression);
|
||||
const kind = HANDLER_ATTR_RE.test(attr.name) ? 'handler' : 'text';
|
||||
const entry = analysis.propFor(text, kind);
|
||||
analysis.replacements.push({ start: part.expression.start, end: part.expression.end, prop: entry.prop });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ClassDirective':
|
||||
case 'StyleDirective': {
|
||||
const expr = attr.expression;
|
||||
if (expr && isFree(expr, scopes)) {
|
||||
const text = exprText(analysis.source, expr);
|
||||
const entry = analysis.propFor(text, 'condition');
|
||||
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'BindDirective':
|
||||
analysis.fail(`bind:${attr.name} requires source-preview mode`);
|
||||
return;
|
||||
case 'UseDirective':
|
||||
analysis.fail(`use:${attr.name} requires source-preview mode`);
|
||||
return;
|
||||
case 'AnimateDirective':
|
||||
case 'TransitionDirective':
|
||||
// Motion directives reference route-scoped or svelte/transition
|
||||
// imports; a detached preview cannot resolve them.
|
||||
analysis.fail(`${attr.type} requires source-preview mode`);
|
||||
return;
|
||||
case 'OnDirective': {
|
||||
// Legacy on:click syntax; treat like handler attributes.
|
||||
const expr = attr.expression;
|
||||
if (expr && isFree(expr, scopes)) {
|
||||
const text = exprText(analysis.source, expr);
|
||||
const entry = analysis.propFor(text, 'handler');
|
||||
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SpreadAttribute':
|
||||
analysis.fail('spread attribute requires source-preview mode');
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the repeating item of an each block for browser-side hydration:
|
||||
* the item's root element (tag + static classes, used to count live
|
||||
* iterations) and the ordered text slots that reference loop bindings.
|
||||
*/
|
||||
function describeEachItem(node, source) {
|
||||
const body = node.body;
|
||||
const rootEl = (body?.nodes || []).find((n) => n.type === 'RegularElement');
|
||||
const bound = new Set();
|
||||
if (node.context) collectPatternNames(node.context, bound);
|
||||
if (node.index) bound.add(node.index);
|
||||
|
||||
const textSlots = [];
|
||||
const staticTexts = [];
|
||||
let nestedUnsupported = false;
|
||||
const collectStatics = (fragment) => {
|
||||
for (const child of fragment?.nodes || []) {
|
||||
if (child.type === 'Text') {
|
||||
const trimmed = String(child.data || '').trim();
|
||||
if (trimmed) staticTexts.push(trimmed);
|
||||
} else if (child.type === 'IfBlock') {
|
||||
collectStatics(child.consequent);
|
||||
if (child.alternate) collectStatics(child.alternate);
|
||||
} else if (child.type === 'EachBlock') {
|
||||
collectStatics(child.body);
|
||||
} else if (child.fragment) {
|
||||
collectStatics(child.fragment);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectStatics(body);
|
||||
const walkForSlots = (fragment, scopes) => {
|
||||
for (const child of fragment?.nodes || []) {
|
||||
if (child.type === 'ExpressionTag') {
|
||||
const roots = collectRootIdentifiers(child.expression);
|
||||
const referencesItem = [...roots].some((name) => scopes.some((s) => s.has(name)));
|
||||
if (referencesItem) {
|
||||
textSlots.push({
|
||||
key: derivePropName(exprText(source, child.expression)),
|
||||
expr: exprText(source, child.expression),
|
||||
});
|
||||
}
|
||||
} else if (child.type === 'EachBlock') {
|
||||
const roots = collectRootIdentifiers(child.expression);
|
||||
const boundNested = [...roots].some((name) => scopes.some((s) => s.has(name)));
|
||||
if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet
|
||||
const innerBound = new Set();
|
||||
if (child.context) collectPatternNames(child.context, innerBound);
|
||||
if (child.index) innerBound.add(child.index);
|
||||
walkForSlots(child.body, [...scopes, innerBound]);
|
||||
} else if (child.type === 'IfBlock') {
|
||||
walkForSlots(child.consequent, scopes);
|
||||
if (child.alternate) walkForSlots(child.alternate, scopes);
|
||||
} else if (child.fragment) {
|
||||
walkForSlots(child.fragment, scopes);
|
||||
}
|
||||
}
|
||||
};
|
||||
walkForSlots(body, [bound]);
|
||||
|
||||
const staticClasses = [];
|
||||
for (const attr of rootEl?.attributes || []) {
|
||||
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
|
||||
for (const part of attr.value) {
|
||||
if (part.type === 'Text') staticClasses.push(...part.data.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rootTag: rootEl?.name || null,
|
||||
rootClasses: staticClasses,
|
||||
textSlots,
|
||||
staticTexts,
|
||||
nestedUnsupported,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a keyed each block's key expression:
|
||||
* { keyField } member of the loop item (e.g. `(expense.id)` when the
|
||||
* context binds `expense`): browser injects a unique
|
||||
* per-index value under that field.
|
||||
* {} key is the whole loop item or the index: already
|
||||
* distinct per iteration, nothing to inject.
|
||||
* { unsupported } free or complex keys: cannot hydrate distinct values.
|
||||
*/
|
||||
function classifyEachKey(node) {
|
||||
const bound = new Set();
|
||||
if (node.context) collectPatternNames(node.context, bound);
|
||||
if (node.index) bound.add(node.index);
|
||||
const key = node.key;
|
||||
const roots = collectRootIdentifiers(key);
|
||||
const usesLoopBinding = [...roots].some((name) => bound.has(name));
|
||||
if (!usesLoopBinding) {
|
||||
// A key that ignores the loop item is constant across iterations:
|
||||
// guaranteed duplicate keys at mount.
|
||||
return { unsupported: 'each key not derived from the loop item requires source-preview mode' };
|
||||
}
|
||||
if (key.type === 'Identifier' && bound.has(key.name)) return {};
|
||||
if (
|
||||
key.type === 'MemberExpression'
|
||||
&& !key.computed
|
||||
&& key.object?.type === 'Identifier'
|
||||
&& bound.has(key.object.name)
|
||||
&& key.property?.type === 'Identifier'
|
||||
) {
|
||||
return { keyField: key.property.name };
|
||||
}
|
||||
return { unsupported: 'complex each key requires source-preview mode' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a fragment's root element for browser presence probing:
|
||||
* { tag, classes } of the first RegularElement, or null for text-only
|
||||
* fragments (which cannot be probed reliably).
|
||||
*/
|
||||
function describeElementProbe(fragment) {
|
||||
const rootEl = (fragment?.nodes || []).find((n) => n.type === 'RegularElement');
|
||||
if (!rootEl) return null;
|
||||
const classes = [];
|
||||
for (const attr of rootEl.attributes || []) {
|
||||
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
|
||||
for (const part of attr.value) {
|
||||
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tag: rootEl.name, classes };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Analyze a markup block and produce the prop-substituted scaffold markup and
|
||||
* the v2 prop contract. Returns { ok: false, reason } when the block needs
|
||||
* source-preview mode (parse failure or unsupported construct).
|
||||
*/
|
||||
export function analyzeSvelteMarkup(markup, parse) {
|
||||
const source = String(markup || '');
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(source, { modern: true });
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `svelte parse failed: ${err.message}` };
|
||||
}
|
||||
if (ast.instance || ast.module) {
|
||||
return { ok: false, reason: 'selected block contains a script tag' };
|
||||
}
|
||||
const analysis = new Analysis(source);
|
||||
analyzeFragment(ast.fragment, analysis, []);
|
||||
if (analysis.unsupported) {
|
||||
return { ok: false, reason: analysis.unsupported };
|
||||
}
|
||||
for (const entry of analysis.contract) {
|
||||
if (entry.kind === 'collection' && entry.item?.nestedUnsupported) {
|
||||
return { ok: false, reason: 'nested per-item each blocks require source-preview mode' };
|
||||
}
|
||||
}
|
||||
|
||||
const markupWithProps = applyReplacements(source, analysis.replacements);
|
||||
return {
|
||||
ok: true,
|
||||
markupWithProps,
|
||||
contract: analysis.contract.map((entry) => ({
|
||||
prop: entry.prop,
|
||||
expr: entry.expr,
|
||||
kind: entry.kind,
|
||||
// Kept for backward compatibility with v1 consumers (fake e2e agent,
|
||||
// text-only restore paths).
|
||||
placeholder: `{${entry.expr}}`,
|
||||
...(entry.item ? { item: entry.item } : {}),
|
||||
...(entry.probe ? { probe: entry.probe } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function applyReplacements(source, replacements) {
|
||||
const sorted = [...replacements].sort((a, b) => b.start - a.start);
|
||||
let out = source;
|
||||
for (const { start, end, prop } of sorted) {
|
||||
out = out.slice(0, start) + prop + out.slice(end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a variant's markup back to route-source form: every free
|
||||
* identifier that matches a contract prop is replaced by its original
|
||||
* expression. AST-based so `{#each stages as stage}` restores to
|
||||
* `{#each data.stages as stage}` even though the prop appears without braces.
|
||||
*/
|
||||
export function restoreSvelteMarkup(markup, contract, parse) {
|
||||
const source = String(markup || '');
|
||||
const byProp = new Map();
|
||||
for (const entry of contract || []) byProp.set(entry.prop, entry.expr);
|
||||
if (byProp.size === 0) return { ok: true, markup: source };
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(source, { modern: true });
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `variant parse failed: ${err.message}` };
|
||||
}
|
||||
|
||||
const replacements = [];
|
||||
const visitExpr = (expression, scopes) => {
|
||||
if (!expression) return;
|
||||
collectFreeIdentifierRanges(expression, scopes, (name, start, end) => {
|
||||
const original = byProp.get(name);
|
||||
if (original != null && original !== name) replacements.push({ start, end, prop: original });
|
||||
});
|
||||
};
|
||||
|
||||
const walk = (fragment, scopes) => {
|
||||
const fragmentScope = new Set();
|
||||
const nextScopes = [...scopes, fragmentScope];
|
||||
for (const node of fragment?.nodes || []) {
|
||||
if (node.type === 'ConstTag' && node.declaration) {
|
||||
for (const decl of node.declaration.declarations || []) collectPatternNames(decl.id, fragmentScope);
|
||||
}
|
||||
}
|
||||
for (const node of fragment?.nodes || []) {
|
||||
switch (node?.type) {
|
||||
case 'ExpressionTag':
|
||||
case 'HtmlTag':
|
||||
visitExpr(node.expression, nextScopes);
|
||||
break;
|
||||
case 'ConstTag':
|
||||
for (const decl of node.declaration?.declarations || []) visitExpr(decl.init, nextScopes);
|
||||
break;
|
||||
case 'EachBlock': {
|
||||
visitExpr(node.expression, nextScopes);
|
||||
if (node.key) visitExpr(node.key, nextScopes);
|
||||
const bound = new Set();
|
||||
if (node.context) collectPatternNames(node.context, bound);
|
||||
if (node.index) bound.add(node.index);
|
||||
walk(node.body, [...nextScopes, bound]);
|
||||
if (node.fallback) walk(node.fallback, nextScopes);
|
||||
break;
|
||||
}
|
||||
case 'IfBlock':
|
||||
visitExpr(node.test, nextScopes);
|
||||
walk(node.consequent, nextScopes);
|
||||
if (node.alternate) walk(node.alternate, nextScopes);
|
||||
break;
|
||||
case 'KeyBlock':
|
||||
visitExpr(node.expression, nextScopes);
|
||||
walk(node.fragment, nextScopes);
|
||||
break;
|
||||
case 'SnippetBlock': {
|
||||
const bound = new Set();
|
||||
for (const param of node.parameters || []) collectPatternNames(param, bound);
|
||||
walk(node.body, [...nextScopes, bound]);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
for (const attr of node?.attributes || []) {
|
||||
if (attr.type === 'Attribute' && Array.isArray(attr.value)) {
|
||||
for (const part of attr.value) {
|
||||
if (part?.type === 'ExpressionTag') visitExpr(part.expression, nextScopes);
|
||||
}
|
||||
} else if (attr.expression) {
|
||||
visitExpr(attr.expression, nextScopes);
|
||||
}
|
||||
}
|
||||
if (node?.fragment) walk(node.fragment, nextScopes);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(ast.fragment, []);
|
||||
|
||||
return { ok: true, markup: applyReplacements(source, replacements) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Report [name, start, end] for every free root identifier READ in an
|
||||
* expression (skips member properties, object keys, shadowed names).
|
||||
*/
|
||||
function collectFreeIdentifierRanges(node, scopes, emit) {
|
||||
const visit = (n, localBound) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { for (const item of n) visit(item, localBound); return; }
|
||||
switch (n.type) {
|
||||
case 'Identifier': {
|
||||
const bound = localBound.has(n.name) || scopes.some((s) => s.has(n.name));
|
||||
if (!bound) emit(n.name, n.start, n.end);
|
||||
return;
|
||||
}
|
||||
case 'MemberExpression':
|
||||
visit(n.object, localBound);
|
||||
if (n.computed) visit(n.property, localBound);
|
||||
return;
|
||||
case 'Property':
|
||||
if (n.computed) visit(n.key, localBound);
|
||||
visit(n.value, localBound);
|
||||
return;
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionExpression': {
|
||||
const inner = new Set(localBound);
|
||||
for (const param of n.params || []) collectPatternNames(param, inner);
|
||||
visit(n.body, inner);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
for (const key of Object.keys(n)) {
|
||||
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
||||
visit(n[key], localBound);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(node, new Set());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the preview component's script block from a v2 contract, with
|
||||
* defaults that keep an unhydrated mount rendering instead of crashing.
|
||||
*/
|
||||
export function buildPropsScriptV2(contract) {
|
||||
if (!contract || contract.length === 0) {
|
||||
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
|
||||
}
|
||||
const defaults = {
|
||||
text: "''",
|
||||
raw: "''",
|
||||
condition: 'false',
|
||||
collection: '[]',
|
||||
handler: '() => {}',
|
||||
};
|
||||
const types = {
|
||||
text: 'string',
|
||||
raw: 'string',
|
||||
condition: 'boolean',
|
||||
collection: 'Array<Record<string, unknown>>',
|
||||
handler: '() => void',
|
||||
};
|
||||
const names = contract
|
||||
.map((c) => `${c.prop} = ${defaults[c.kind] ?? "''"}`)
|
||||
.join(', ');
|
||||
const typeFields = contract
|
||||
.map((c) => ` ${c.prop}?: ${types[c.kind] ?? 'string'};`)
|
||||
.join('\n');
|
||||
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
|
||||
}
|
||||
@@ -10,9 +10,38 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
analyzeSvelteMarkup,
|
||||
buildPropsScriptV2,
|
||||
loadSvelteCompiler,
|
||||
restoreSvelteMarkup,
|
||||
} from './svelte-ast.mjs';
|
||||
import {
|
||||
bakeParamValues,
|
||||
collectAllSelectors,
|
||||
collectUnusedSelectors,
|
||||
normalizeSelector,
|
||||
parseStylesheet,
|
||||
pruneUnusedSelectors,
|
||||
reconcileCss,
|
||||
serializeNodes,
|
||||
splitSelectorList,
|
||||
} from './accept-css.mjs';
|
||||
import { verifyAcceptedSource } from './accept-verify.mjs';
|
||||
|
||||
// Preview modules stay under node_modules on purpose: SvelteKit restricts
|
||||
// vite's server.fs.allow to src/lib, src/routes, .svelte-kit, and
|
||||
// node_modules, so an .impeccable/ tree under the app root 403s (verified
|
||||
// against a real SvelteKit dev server). Staleness from node_modules being
|
||||
// unwatched is solved by REVISIONED module paths instead: every publish
|
||||
// snapshots the variant files into a fresh r<N>/ directory and the browser
|
||||
// imports from there, so a republished fix can never be pinned by a
|
||||
// transform cache keyed on the old path.
|
||||
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
|
||||
// A short-lived interim location; swept so no project keeps a stray tree.
|
||||
export const LEGACY_SVELTE_COMPONENT_ROOT = '.impeccable/live/previews';
|
||||
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
|
||||
export const SVELTE_PROBE_FILE = `${SVELTE_COMPONENT_ROOT}/__probe.js`;
|
||||
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
|
||||
|
||||
const MUSTACHE_RE = /\{([^{}]+)\}/g;
|
||||
@@ -32,9 +61,18 @@ export function manifestPathForSession(id, cwd = process.cwd()) {
|
||||
|
||||
export function ensureRuntimeHelper(cwd = process.cwd()) {
|
||||
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
|
||||
if (fs.existsSync(file)) return file;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
|
||||
if (!fs.existsSync(file)) {
|
||||
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
|
||||
}
|
||||
// Attach-time probe: the browser imports this through the dev server before
|
||||
// the first mount. A 404 here means the resolved app root and the dev
|
||||
// server's root disagree, and the session fails with a named error instead
|
||||
// of a silent fall-back to the picker at first variant.
|
||||
const probe = path.join(cwd, SVELTE_PROBE_FILE);
|
||||
if (!fs.existsSync(probe)) {
|
||||
fs.writeFileSync(probe, `export const impeccableLivePreviewProbe = true;\n`, 'utf-8');
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -136,6 +174,14 @@ function buildInsertVariantStub(variantNum) {
|
||||
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold a component-preview session. The scaffold is AST-based: the app's
|
||||
* own svelte compiler parses the selected markup, control-flow blocks are
|
||||
* preserved (an each collection crosses the prop contract as ONE structured
|
||||
* prop, its loop body verbatim), and constructs a detached preview cannot
|
||||
* support return `{ fallback: 'source-preview', reason }` so the caller keeps
|
||||
* the markup inside the route file instead of shipping a wrong preview.
|
||||
*/
|
||||
export function scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
@@ -145,17 +191,31 @@ export function scaffoldSvelteComponentSession({
|
||||
originalLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
|
||||
const compiler = loadSvelteCompiler(cwd);
|
||||
if (!compiler) {
|
||||
return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' };
|
||||
}
|
||||
const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse);
|
||||
if (!analysis.ok) {
|
||||
return { fallback: 'source-preview', reason: analysis.reason };
|
||||
}
|
||||
|
||||
ensureRuntimeHelper(cwd);
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
|
||||
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
|
||||
const contract = analysis.contract;
|
||||
const seededCss = extractMatchingSourceCss(
|
||||
safeReadSource(path.resolve(cwd, sourceFile)),
|
||||
originalMarkup,
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'svelte-component',
|
||||
contractVersion: 2,
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
@@ -163,7 +223,14 @@ export function scaffoldSvelteComponentSession({
|
||||
propContract: contract,
|
||||
originalMarkup,
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
// Absolute paths let the browser fall back to /@fs/ imports when the dev
|
||||
// server's base or root makes root-relative URLs miss, and probe whether
|
||||
// the preview tree is reachable at all before blaming a variant.
|
||||
componentDirAbs: dir.split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
|
||||
probeModule: `/${SVELTE_PROBE_FILE}`,
|
||||
probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
@@ -171,7 +238,7 @@ export function scaffoldSvelteComponentSession({
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const variantFile = path.join(dir, `v${n}.svelte`);
|
||||
if (!fs.existsSync(variantFile)) {
|
||||
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
|
||||
fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +250,59 @@ export function scaffoldSvelteComponentSession({
|
||||
};
|
||||
}
|
||||
|
||||
function safeReadSource(filePath) {
|
||||
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed variant stubs with the source component's rules that already style the
|
||||
* selected markup, so variants start from the real cascade (a detached
|
||||
* preview inherits none of the route's compile-scoped CSS) instead of
|
||||
* reimplementing it blind.
|
||||
*/
|
||||
export function extractMatchingSourceCss(routeSource, originalMarkup) {
|
||||
const styleMatch = String(routeSource || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
|
||||
if (!styleMatch) return '';
|
||||
const classNames = new Set();
|
||||
const classRe = /class\s*=\s*(["'])(.*?)\1/g;
|
||||
let m;
|
||||
while ((m = classRe.exec(originalMarkup))) {
|
||||
for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls);
|
||||
}
|
||||
const tagRe = /<([a-z][a-z0-9-]*)/gi;
|
||||
const tags = new Set();
|
||||
while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase());
|
||||
if (classNames.size === 0 && tags.size === 0) return '';
|
||||
|
||||
const selectorMatches = (prelude) => splitSelectorList(prelude).some((selector) => {
|
||||
for (const cls of classNames) if (selector.includes(`.${cls}`)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const pick = (nodes) => {
|
||||
const kept = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'rule' && selectorMatches(node.prelude)) kept.push(node);
|
||||
else if (node.type === 'at' && node.children) {
|
||||
const children = pick(node.children);
|
||||
if (children.length) kept.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
return serializeNodes(pick(parseStylesheet(styleMatch[1])));
|
||||
}
|
||||
|
||||
function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) {
|
||||
const propsComment = contract.length > 0
|
||||
? `\n<!-- Props: ${contract.map((c) => `${c.prop} (${c.kind}) <- {${c.expr}}`).join(', ')} -->\n`
|
||||
: '';
|
||||
const css = seededCss
|
||||
? `\n<style>\n /* Variant ${variantNum}: seeded from the route's current rules; restyle freely */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>\n`
|
||||
: `\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
|
||||
return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
@@ -213,7 +333,11 @@ export function scaffoldSvelteComponentInsertSession({
|
||||
count,
|
||||
propContract: [],
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
componentDirAbs: dir.split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
|
||||
probeModule: `/${SVELTE_PROBE_FILE}`,
|
||||
probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
@@ -238,16 +362,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) {
|
||||
if (fs.existsSync(direct)) {
|
||||
return readManifest(direct);
|
||||
}
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return null;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(root, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const manifest = readManifest(candidate);
|
||||
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
|
||||
} catch { /* skip */ }
|
||||
// Legacy location: a session scaffolded by an older version can still be
|
||||
// accepted after an upgrade.
|
||||
const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json');
|
||||
if (fs.existsSync(legacyDirect)) {
|
||||
return readManifest(legacyDirect);
|
||||
}
|
||||
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
|
||||
const root = path.join(cwd, rootRel);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(root, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const manifest = readManifest(candidate);
|
||||
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -451,35 +583,6 @@ function rewriteParamSelectors(selector, paramValues) {
|
||||
return { keep, selector: next };
|
||||
}
|
||||
|
||||
function splitSelectorList(prelude) {
|
||||
const selectors = [];
|
||||
let start = 0;
|
||||
let bracket = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
for (let i = 0; i < prelude.length; i++) {
|
||||
const ch = prelude[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') bracket++;
|
||||
else if (ch === ']') bracket = Math.max(0, bracket - 1);
|
||||
else if (ch === '(') paren++;
|
||||
else if (ch === ')') paren = Math.max(0, paren - 1);
|
||||
else if (ch === ',' && bracket === 0 && paren === 0) {
|
||||
selectors.push(prelude.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
selectors.push(prelude.slice(start));
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function selectorHasVariant(selector, variantNum) {
|
||||
return variantSelectorRegex(variantNum).test(selector);
|
||||
@@ -527,10 +630,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
|
||||
|
||||
const rootTag = matchOpeningTag(markup)?.tag || 'div';
|
||||
const contract = manifest.propContract || [];
|
||||
const compiler = loadSvelteCompiler(cwd);
|
||||
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
|
||||
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
|
||||
// Restore props back to route expressions. Contract v2 restores through the
|
||||
// AST so a prop used without braces (each headers, attribute positions)
|
||||
// still maps back to its original expression; v1 falls back to the textual
|
||||
// placeholder swap.
|
||||
let restoredText;
|
||||
if (Number(manifest.contractVersion) === 2 && compiler) {
|
||||
const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse);
|
||||
if (!restored.ok) {
|
||||
return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase };
|
||||
}
|
||||
restoredText = restored.markup;
|
||||
} else {
|
||||
restoredText = substitutePropsWithExprs(mergedMarkup, contract);
|
||||
}
|
||||
const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd());
|
||||
|
||||
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
@@ -541,10 +658,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
|
||||
}
|
||||
|
||||
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, start),
|
||||
@@ -552,25 +666,145 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
|
||||
...sourceLines.slice(end + 1),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
// Selectors that were already unused before this accept are the user's
|
||||
// pre-existing code; the pruning pass must not touch them.
|
||||
const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set();
|
||||
|
||||
// Bake params (declared kinds from params.json drive branch pruning), then
|
||||
// MERGE into the component's existing style block: matching selectors are
|
||||
// replaced, new ones appended. Appending alone is how superseded rules used
|
||||
// to survive their own replacement.
|
||||
const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
|
||||
let variantCss = cssLines.join('\n');
|
||||
if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
|
||||
// Defensive: strip preview-wrapper selectors that authoring rules forbid
|
||||
// on this path but an off-spec agent may still emit.
|
||||
variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
|
||||
}
|
||||
const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
|
||||
const cssStats = { replaced: 0, appended: 0, pruned: [] };
|
||||
if (bakedCss.trim()) {
|
||||
const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
|
||||
newLines = merged.text.split('\n');
|
||||
cssStats.replaced = merged.replaced;
|
||||
cssStats.appended = merged.appended;
|
||||
}
|
||||
|
||||
let finalText = newLines.join('\n');
|
||||
if (compiler) {
|
||||
const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused });
|
||||
finalText = pruned.source;
|
||||
cssStats.pruned = pruned.removed;
|
||||
}
|
||||
|
||||
// Postcondition: no selector from the user's pre-accept CSS may vanish
|
||||
// unless the compiler-driven prune deliberately removed it. This turns any
|
||||
// parser or reconciler defect into a loud refusal instead of silent damage
|
||||
// to a hand-written style block.
|
||||
const lostSelectors = findLostSelectors(sourceContent, finalText, cssStats.pruned);
|
||||
if (lostSelectors.length > 0) {
|
||||
return {
|
||||
handled: false,
|
||||
error: 'CSS reconciliation would lose selectors from the existing style block: '
|
||||
+ lostSelectors.join(', ')
|
||||
+ '. Source not modified; accept the variant manually.',
|
||||
mode: 'error',
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
|
||||
fs.writeFileSync(sourceFile, finalText, 'utf-8');
|
||||
} catch (err) {
|
||||
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
const verify = verifyAcceptedSource(finalText);
|
||||
return {
|
||||
handled: true,
|
||||
css: cssStats,
|
||||
verify,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-indent a block onto `indent` while preserving its internal structure. */
|
||||
export function reindentPreservingStructure(lines, indent) {
|
||||
const nonEmpty = lines.filter((line) => line.trim() !== '');
|
||||
if (nonEmpty.length === 0) return lines.map(() => '');
|
||||
const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length));
|
||||
return lines.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
const current = (line.match(/^\s*/) || [''])[0].length;
|
||||
return indent + line.slice(Math.min(minIndent, current));
|
||||
});
|
||||
}
|
||||
|
||||
function styleBlockText(sourceText) {
|
||||
const match = String(sourceText || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) {
|
||||
const before = collectAllSelectors(styleBlockText(beforeSource));
|
||||
const after = collectAllSelectors(styleBlockText(afterSource));
|
||||
const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s)));
|
||||
const lost = [];
|
||||
for (const selector of before) {
|
||||
if (!after.has(selector) && !pruned.has(selector)) lost.push(selector);
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
function readDeclaredParams(manifest, variantNum, cwd) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8'));
|
||||
const list = raw?.[String(variantNum)];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge CSS into a svelte component's top-level style block (created when
|
||||
* absent), replacing rules whose selectors match and appending the rest.
|
||||
*/
|
||||
export function mergeCssIntoSvelteSource(sourceText, incomingCss) {
|
||||
const text = String(sourceText || '');
|
||||
const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
||||
let lastMatch = null;
|
||||
let m;
|
||||
while ((m = styleRe.exec(text))) lastMatch = m;
|
||||
|
||||
if (!lastMatch) {
|
||||
const { css, replaced, appended } = reconcileCss('', incomingCss);
|
||||
return {
|
||||
text: `${text.replace(/\s*$/, '')}\n\n<style>\n${indentCssBlock(css)}\n</style>\n`,
|
||||
replaced,
|
||||
appended,
|
||||
};
|
||||
}
|
||||
|
||||
const inner = lastMatch[1];
|
||||
const { css, replaced, appended } = reconcileCss(inner, incomingCss);
|
||||
const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
|
||||
const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n</style>`;
|
||||
return {
|
||||
text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length),
|
||||
replaced,
|
||||
appended,
|
||||
};
|
||||
}
|
||||
|
||||
function indentCssBlock(css) {
|
||||
return String(css || '')
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() === '' ? '' : ' ' + line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function inlineSvelteComponentInsertAccept({
|
||||
manifest,
|
||||
markup,
|
||||
@@ -601,10 +835,7 @@ function inlineSvelteComponentInsertAccept({
|
||||
|
||||
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
|
||||
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, insertIndex),
|
||||
@@ -612,10 +843,15 @@ function inlineSvelteComponentInsertAccept({
|
||||
...sourceLines.slice(insertIndex),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
let variantCss = cssLines.join('\n');
|
||||
if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
|
||||
variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
|
||||
}
|
||||
const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
|
||||
const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
|
||||
if (bakedCss.trim()) {
|
||||
const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
|
||||
newLines = merged.text.split('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -625,8 +861,10 @@ function inlineSvelteComponentInsertAccept({
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
const verify = verifyAcceptedSource(newLines.join('\n'));
|
||||
return {
|
||||
handled: true,
|
||||
verify,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
@@ -729,18 +967,124 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the agent-authored variant files into a fresh revision directory
|
||||
* and stamp the manifest. Called by the server on every publish (`done`
|
||||
* reply) for a component session; the browser imports from the revision dir,
|
||||
* so the dev server can never serve a stale compile of a republished file.
|
||||
*/
|
||||
export function bumpSvelteComponentPreviewRevision(id, cwd = process.cwd()) {
|
||||
const manifest = findSvelteComponentManifest(id, cwd);
|
||||
if (!manifest || !manifest.manifestPath) return null;
|
||||
const sessionDir = path.dirname(manifest.manifestPath);
|
||||
const revision = Number(manifest.revision || 0) + 1;
|
||||
const revDirName = `r${revision}`;
|
||||
const revDir = path.join(sessionDir, revDirName);
|
||||
try {
|
||||
fs.mkdirSync(revDir, { recursive: true });
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(sessionDir, { withFileTypes: true }); } catch { /* empty */ }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name === 'manifest.json') continue;
|
||||
fs.copyFileSync(path.join(sessionDir, entry.name), path.join(revDir, entry.name));
|
||||
}
|
||||
// Previous revision dirs are dead the moment a new one exists.
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && /^r\d+$/.test(entry.name) && entry.name !== revDirName) {
|
||||
try { fs.rmSync(path.join(sessionDir, entry.name), { recursive: true, force: true }); } catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
const relSessionDir = path.relative(cwd, sessionDir).split(path.sep).join('/');
|
||||
const updated = {
|
||||
...manifest,
|
||||
revision,
|
||||
revisionDir: `${relSessionDir}/${revDirName}`,
|
||||
revisionDirAbs: revDir.split(path.sep).join('/'),
|
||||
};
|
||||
delete updated.manifestPath;
|
||||
fs.writeFileSync(manifest.manifestPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
|
||||
return { revision, revisionDir: updated.revisionDir };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop-path sweep. The whole `node_modules/.impeccable-live` tree is
|
||||
* impeccable-owned and gitignored, so once no session should survive there is
|
||||
* nothing left worth keeping: the per-session dirs, the generated
|
||||
* `__runtime.js`, and the parent directory all go. The old per-entry loop
|
||||
* skipped `__*` entries and the parent, which left the runtime shim and an
|
||||
* empty directory in every project that ever ran live mode once.
|
||||
*/
|
||||
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('__')) continue;
|
||||
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
|
||||
const root = path.join(cwd, rootRel);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-path sweep. A restart must not delete the tree wholesale: sessions
|
||||
* recorded in the session store may still be mid-generation. Remove only the
|
||||
* session dirs whose id has no active snapshot, then drop `__runtime.js` and
|
||||
* the parent directory when nothing is left to serve.
|
||||
*
|
||||
* @param {Iterable<string>} activeIds session ids that must be preserved
|
||||
* @returns {{ removed: string[], removedRoot: boolean, kept: string[] }}
|
||||
*/
|
||||
export function sweepInactiveSvelteComponentSessions(activeIds = [], cwd = process.cwd()) {
|
||||
const result = { removed: [], removedRoot: false, kept: [] };
|
||||
const active = new Set();
|
||||
for (const id of activeIds || []) {
|
||||
if (typeof id === 'string' && id) active.add(id);
|
||||
}
|
||||
|
||||
for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
|
||||
const root = path.join(cwd, rootRel);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let keptHere = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('__')) continue;
|
||||
if (active.has(entry.name)) {
|
||||
result.kept.push(entry.name);
|
||||
keptHere++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
||||
result.removed.push(entry.name);
|
||||
} catch {
|
||||
// Could not remove it, so it still occupies the tree; treat it as kept
|
||||
// so the parent directory is not torn out from under it.
|
||||
result.kept.push(entry.name);
|
||||
keptHere++;
|
||||
}
|
||||
}
|
||||
|
||||
if (keptHere === 0) {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
result.removedRoot = true;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deferredAcceptsPath(cwd = process.cwd()) {
|
||||
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
|
||||
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
|
||||
@@ -34,3 +34,138 @@ export const LIVE_COMMANDS = [
|
||||
|
||||
// Action values accepted by the live event protocol, in palette order.
|
||||
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
|
||||
|
||||
/*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Protocol vocabulary
|
||||
* ---------------------------------------------------------------------------
|
||||
* The enums below are the wire contract between the browser overlay, the live
|
||||
* helper server, and the durable session journal. They live here rather than in
|
||||
* the modules that use them so a value cannot be added to the validator without
|
||||
* the store and the server seeing it too.
|
||||
*
|
||||
* live-browser.js still cannot import this file (it is served raw and injected
|
||||
* as an IIFE), so its local phase table repeats the agent-phase names. Anything
|
||||
* the server can broadcast must appear in AGENT_PHASES here first.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phases the live server broadcasts as `agent_phase`, in lifecycle order.
|
||||
* Every one of these is emitted by `recordAgentPhase()` in live-server.mjs;
|
||||
* the validator rejects anything else, so a typo in a phase name fails loudly
|
||||
* instead of quietly ranking as an unknown phase in the browser's progress bar.
|
||||
*/
|
||||
export const AGENT_PHASES = Object.freeze([
|
||||
'picked_up',
|
||||
'scaffolding',
|
||||
'source_ready',
|
||||
'scaffold_fallback',
|
||||
'generation_ready',
|
||||
'first_reviewable',
|
||||
'second_reviewable',
|
||||
'all_variants_ready',
|
||||
]);
|
||||
|
||||
/** Event types the helper server accepts from the browser over POST /events. */
|
||||
export const CLIENT_EVENT_TYPES = Object.freeze([
|
||||
'generate',
|
||||
'accept',
|
||||
'discard',
|
||||
'checkpoint',
|
||||
'agent_phase',
|
||||
'variant_mounted',
|
||||
'variant_mount_failed',
|
||||
'exit',
|
||||
'prefetch',
|
||||
'manual_edits',
|
||||
'steer',
|
||||
'carbonize_cleanup',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Event types the durable journal applies. A superset of CLIENT_EVENT_TYPES:
|
||||
* the agent-side helpers (live-poll, live-complete) and the server itself
|
||||
* append the rest. An event type missing here lands as `unknown_event_type`
|
||||
* in the snapshot diagnostics.
|
||||
*/
|
||||
export const JOURNAL_EVENT_TYPES = Object.freeze([
|
||||
'generate',
|
||||
'variant_plan',
|
||||
'detector_waivers',
|
||||
'agent_phase',
|
||||
'variants_ready',
|
||||
'agent_done',
|
||||
'variant_mounted',
|
||||
'variant_mount_failed',
|
||||
'checkpoint',
|
||||
'accept',
|
||||
'accept_intent',
|
||||
'manual_edit_apply',
|
||||
'steer',
|
||||
'steer_done',
|
||||
'carbonize_cleanup',
|
||||
'discard',
|
||||
'discarded',
|
||||
'complete',
|
||||
'agent_error',
|
||||
]);
|
||||
|
||||
/** Phases the session store assigns to a snapshot. */
|
||||
export const SESSION_PHASES = Object.freeze([
|
||||
'new',
|
||||
'generate_requested',
|
||||
'variants_ready',
|
||||
'carbonize_required',
|
||||
'carbonize_cleanup_requested',
|
||||
'manual_edit_apply_requested',
|
||||
'steer_requested',
|
||||
'steer_done',
|
||||
'accept_requested',
|
||||
'discard_requested',
|
||||
'discarded',
|
||||
'completed',
|
||||
'agent_error',
|
||||
]);
|
||||
|
||||
/** Phases that retire a session from the active list. */
|
||||
export const COMPLETED_SESSION_PHASES = Object.freeze(['completed', 'discarded']);
|
||||
|
||||
/**
|
||||
* Phases after which a late generation write is a ghost from a canceled cycle.
|
||||
* The store journals such an event as a diagnostic instead of applying it.
|
||||
*/
|
||||
export const GENERATION_FENCED_SESSION_PHASES = Object.freeze([
|
||||
'accept_requested',
|
||||
'discard_requested',
|
||||
'carbonize_required',
|
||||
'completed',
|
||||
'discarded',
|
||||
]);
|
||||
|
||||
/**
|
||||
* `reason` values carried on checkpoint events. Not validated (an unknown
|
||||
* reason is journaled, never rejected) because the reason is diagnostic
|
||||
* breadcrumb, not control flow. Two exceptions drive behavior and are split
|
||||
* out below.
|
||||
*/
|
||||
export const CHECKPOINT_REASONS = Object.freeze([
|
||||
'generate_started',
|
||||
'variants_progress',
|
||||
'variants_ready',
|
||||
'browser_resumed',
|
||||
'browser_resumed_svelte_component',
|
||||
'param_changed',
|
||||
'variant_anchor_missing',
|
||||
'component_preview_anchor_missing',
|
||||
'steer_input_focused',
|
||||
'steer_submitted',
|
||||
'steer_send_failed',
|
||||
'steer_done',
|
||||
'steer_error',
|
||||
]);
|
||||
|
||||
/** Checkpoint reasons the server reads as variant-publication progress. */
|
||||
export const VARIANT_PROGRESS_CHECKPOINT_REASONS = Object.freeze([
|
||||
'variants_progress',
|
||||
'variants_ready',
|
||||
]);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -22,6 +22,7 @@ import { detectCsp } from '../skill/scripts/detect-csp.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPTS_DIR = join(__dirname, '..', 'skill', 'scripts');
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
|
||||
|
||||
function listFixtures() {
|
||||
@@ -46,6 +47,22 @@ function stageFixture(name) {
|
||||
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
|
||||
|
||||
// The AST scaffolder resolves the app's svelte compiler from the staged
|
||||
// root; the runtime suite gets it from a real npm install, the static sweep
|
||||
// links this repo's devDependency so svelte fixtures take the
|
||||
// component-preview path here too.
|
||||
const repoSvelte = join(REPO_ROOT, 'node_modules', 'svelte');
|
||||
if (existsSync(repoSvelte) && !existsSync(join(tmp, 'node_modules', 'svelte'))) {
|
||||
mkdirSync(join(tmp, 'node_modules'), { recursive: true });
|
||||
try {
|
||||
symlinkSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), 'dir');
|
||||
} catch {
|
||||
// Windows without Developer Mode cannot symlink; copying is slower but
|
||||
// keeps the suite runnable there.
|
||||
cpSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
execFileSync('git', ['init', '-q'], { cwd: tmp });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
|
||||
execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp });
|
||||
|
||||
@@ -37,6 +37,7 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
|
||||
},
|
||||
"runtime": {
|
||||
"styling": "plain-css | tailwind-v4 | styled-components | ...",
|
||||
"appDir": "website",
|
||||
"install": ["npm", "install"],
|
||||
"devCommand": ["npm", "run", "dev"],
|
||||
"scheme": "http",
|
||||
@@ -44,6 +45,27 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"pickPosition": { "x": 10, "y": 10 },
|
||||
"variantSequence": [3, 1, 2],
|
||||
"acceptedSourcePattern": "<ul[^>]*class=\"[^\"]*\\bexpense-list\\b",
|
||||
"assertSourceContains": ["{#each expenses as expense, i}"],
|
||||
"stateProbe": {
|
||||
"textSelector": "[data-testid='open-count']",
|
||||
"expectedText": "3 offen",
|
||||
"windowProperty": "__impeccableStatefulMounts",
|
||||
"expectedWindowValue": 1,
|
||||
"expectWindowUnchanged": true
|
||||
},
|
||||
"paramsScenario": {
|
||||
"variant": 2,
|
||||
"rangeLabel": "Lead",
|
||||
"rangeValue": 1.8,
|
||||
"stepsLabel": "Density",
|
||||
"stepsOptionLabel": "Snug",
|
||||
"expectSourceContains": ["line-height: 1.8"],
|
||||
"expectSourceMissing": ["letter-spacing: 0.14em"]
|
||||
},
|
||||
"componentFailureScenarios": { "variant": 2, "storageLoss": false },
|
||||
"mode": "insert",
|
||||
"insert": {
|
||||
"anchorSelector": "section#features",
|
||||
@@ -84,10 +106,70 @@ The `runtime` block is optional. Fixtures without it only run the static unit ch
|
||||
6. Runs a **Steer smoke** step (unless `runtime.steer === false`): submit a message in the global Steer bar, wait for the fake agent to reply `steer_done`, assert the bar unlocks and a `data-impeccable-steer` marker lands in source + DOM. Then continues with pick → Go → cycle → accept.
|
||||
7. Tears everything down (Playwright close, dev server SIGTERM, live-server stop, tmp rm).
|
||||
|
||||
### `runtime.appDir`
|
||||
|
||||
Optional, defaults to `.`. Set it when the served app is **not** the repo root, the shape live mode has to resolve on its own (a CLI package at the root with the site in `website/`, for example). With `appDir` set, the harness:
|
||||
|
||||
- stages `files/` and runs `git init` at the tmp root, as always;
|
||||
- writes `.impeccable/live/config.json` under `<tmp>/<appDir>/`, and treats every fixture-relative path in `fixture.json` (`steer.sourceFile`, manual-edit `expectedSourceFile`, and so on) as relative to that app dir;
|
||||
- runs `runtime.install` and `runtime.devCommand` with the app dir as cwd;
|
||||
- boots through `live.mjs` **from the tmp root** instead of calling `live-server.mjs` and `live-inject.mjs` directly, so the run exercises root resolution (`skill/scripts/live/roots.mjs`) rather than assuming it. The parsed `live.mjs` payload is exposed as `session.liveBoot`, and `tests/live-e2e.test.mjs` asserts on `roots.appRoot`, `roots.contextRoot`, the persisted `roots.json`, and the repo-root pointer.
|
||||
|
||||
The session object carries both paths: `session.tmp` is the repo root (use it for git and for artifact capture) and `session.appRoot` is the app. They are the same directory for every fixture without `appDir`.
|
||||
|
||||
### Picking, cycling, and the render proof
|
||||
|
||||
`pickSelector` names the element the run picks. The picker resolves whatever is
|
||||
under the cursor, so a **container whose centre is covered by a child can never
|
||||
be picked**: add `pickPosition` (`{x, y}` in px from the element's top-left) to
|
||||
aim at a point the container owns, such as its own padding.
|
||||
|
||||
`variantSequence` (default `[2]`) is the order the run cycles through; the last
|
||||
entry is the variant it accepts. Every variant the run lands on gets a computed
|
||||
`font-weight` assertion in fake-agent mode, because the fake agent renders each
|
||||
variant at a distinct weight (`FAKE_VARIANT_FONT_WEIGHTS` in
|
||||
`tests/live-e2e/agent.mjs`: 300 / 900 / 600). That turns "variant N is visible"
|
||||
from a bar-label claim into a render fact, so a sequence like `[3, 1, 2]` proves
|
||||
all three variants really render.
|
||||
|
||||
`acceptedSourcePattern` overrides the default post-accept source check (an `<h1
|
||||
class="hero-title">`), and `assertSourceContains` lists strings that must
|
||||
survive the whole wrap → accept → carbonize cycle. On Svelte component previews
|
||||
that is how a fixture proves control flow was not flattened: list the `{#each}`
|
||||
header and the per-item expressions.
|
||||
|
||||
`stateProbe` asserts page state is not lost. `textSelector` / `expectedText`
|
||||
check rendered state; `windowProperty` with `expectedWindowValue` and
|
||||
`expectWindowUnchanged` check a counter the app bumps on mount, so a scaffold
|
||||
that silently remounted the page fails. It runs after preActions, after the
|
||||
variants land, and (outside component previews) after accept.
|
||||
|
||||
### Extra scenarios
|
||||
|
||||
Beyond the core cycle, a fixture opts into scenarios by declaring their config.
|
||||
Each one is also gated by `IMPECCABLE_E2E_SCENARIOS`:
|
||||
|
||||
| Scenario name | Enabled by | What it proves |
|
||||
|---|---|---|
|
||||
| `params` | `runtime.paramsScenario` | Dial a `range` and a `steps` knob in the real Tune popover, accept, and assert the chosen values are baked into source as literals with the unchosen branch dropped and no `data-p-*` / `var(--p-*)` left behind. |
|
||||
| `mount-failure` | `runtime.componentFailureScenarios` | Corrupt the published `r<N>/v<variant>` revision file, step onto it, and assert the persistent mount-error card appears, the session survives (bar + localStorage intact), and a `variant_mount_failed` event lands in the session journal. Then restore, Retry, and reach the variant again. |
|
||||
| `republish` | `runtime.componentFailureScenarios` | Re-author every variant and reply `done` again; the browser must mount the new content, which is what the server's revision-dir bump exists to guarantee. |
|
||||
| `storage-loss` | `runtime.componentFailureScenarios` (unless `storageLoss: false`) | Clear localStorage, reload, and assert the comparison comes back from the server's durable session record alone. |
|
||||
|
||||
`componentFailureScenarios.variant` picks which variant to break or observe.
|
||||
Set `storageLoss: false` for fixtures whose picked element only exists after
|
||||
preActions: the reload discards that state, and re-creating it races the
|
||||
preview mount.
|
||||
|
||||
These scenarios assert on deterministic content, so they skip under
|
||||
`IMPECCABLE_E2E_AGENT=llm`.
|
||||
|
||||
One gotcha: `tests/framework-fixtures.test.mjs` stages the same fixture flat and writes the live config at the tmp root, so `config.files` has to resolve from both the repo root and the app root. A glob (`"**/index.html"`) satisfies both; a literal `"index.html"` only works from the app root and makes the static sweep report `file_not_found`.
|
||||
|
||||
Useful runtime E2E filters:
|
||||
|
||||
- `IMPECCABLE_E2E_ONLY=<fixture>[,<fixture>]` scopes the run to selected fixture names.
|
||||
- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, and exit probes.
|
||||
- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, exit, params, and the component failure-injection probes. Names: `core`, `manual`, `annotations`, `exit`, `missed-done`, `params`, `mount-failure`, `republish`, `storage-loss`.
|
||||
- `IMPECCABLE_E2E_TEST_TIMEOUT_MS`, `IMPECCABLE_E2E_INSTALL_TIMEOUT_MS`, and `IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS` tighten CI smoke timeouts without changing fixture metadata.
|
||||
|
||||
Optional `runtime.steer` fields:
|
||||
@@ -112,6 +194,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `vite8-sveltekit-stateful/` | Svelte 5 route with `$state`, an `{#if}` branch, and an `{#each}` list. Picks the list container, so the component-preview scaffold has to carry the loop across as one `collection` prop and hydrate its items from the live DOM. Also carries the params, failure-injection, and state-preservation probes. |
|
||||
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
|
||||
| `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). |
|
||||
| `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. |
|
||||
@@ -120,5 +203,6 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
| `sveltekit-csp/` | SvelteKit `kit.csp.directives` in `svelte.config.js`. CSP shape `append-arrays`. |
|
||||
| `nuxt-csp/` | Nuxt `routeRules` with literal CSP header in `nuxt.config.ts`. CSP shape `append-string`. |
|
||||
| `monorepo-nested-vite/` | Repo root is a CLI package with no dev config and no workspaces; the served Vite + React app lives in `website/`. Exercises `runtime.appDir` and live-mode root resolution. |
|
||||
|
||||
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
|
||||
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`. A fixture with a `runtime` block also needs its name added to the live-e2e matrices in `.github/workflows/ci.yml`: the `live-e2e-full` group list, and one `live-e2e-smoke` group when it should run on every PR. Keep the groups roughly the same size, since they run in parallel and the job times out at 15 minutes.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Nested Website Fixture
|
||||
description: A one-page site for a command-line tool, set in plain type on paper-white surfaces.
|
||||
colors:
|
||||
ink: "#142720"
|
||||
paper: "#ffffff"
|
||||
muted: "#555555"
|
||||
hairline: "#dddddd"
|
||||
typography:
|
||||
display:
|
||||
fontFamily: "system-ui, sans-serif"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.1
|
||||
body:
|
||||
fontFamily: "system-ui, sans-serif"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
rounded:
|
||||
sm: "6px"
|
||||
md: "8px"
|
||||
---
|
||||
|
||||
# Design System: Nested Website Fixture
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A single scrolling page on a paper-white ground. Hairline borders carry the structure; no fills, no shadows.
|
||||
|
||||
## 2. Color
|
||||
|
||||
Ink for text, paper for the page, muted grey for secondary copy, hairline grey for card borders.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
System UI throughout. The hero title runs at 2rem and bold; body copy stays at the browser default size.
|
||||
|
||||
## 4. Components
|
||||
|
||||
Cards are bordered rectangles with 1rem of padding. Actions are inline text buttons with a hairline border and pill-free geometry.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Maintainers of a small command-line tool who visit the project site to check what the tool does before installing it.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
The repository ships a CLI. The marketing site in `website/` explains the CLI and is the only browser-facing surface here.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
The repo root is a package with no dev server and no workspace declaration. Every runnable app lives one level down, in `website/`.
|
||||
|
||||
## Product Principles
|
||||
|
||||
- Say what the tool does before saying how it feels.
|
||||
- One page, read top to bottom, no navigation chrome.
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "fake-cli-tool",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Nested Website Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "monorepo-nested-vite-website",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const foundationCards = [
|
||||
{ label: 'Typography', detail: 'Readable hierarchy' },
|
||||
{ label: 'Color & Contrast', detail: 'Accessible palettes' },
|
||||
{ label: 'Interaction', detail: 'Responsive states' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero-copy">
|
||||
<h1 className="hero-title">Nested Website Fixture</h1>
|
||||
<p className="hero-hook">The served app lives in website/, one level below the repo root.</p>
|
||||
</section>
|
||||
<section id="features" className="feature-grid">
|
||||
<article className="feature-card">One</article>
|
||||
<article className="feature-card">Two</article>
|
||||
</section>
|
||||
<section className="foundation-grid" aria-label="Foundation cards">
|
||||
{foundationCards.map((card) => (
|
||||
<article className="foundation-card" key={card.label}>
|
||||
<span className="foundation-card-label">{card.label}</span>
|
||||
<p className="foundation-card-detail">{card.detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className="action-row" aria-label="Workshop actions">
|
||||
<span className="primary-action" role="button" tabIndex="0">Learn more</span>
|
||||
<span className="secondary-action" role="button" tabIndex="0">Learn more</span>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
.page { padding: 2rem; }
|
||||
.hero-copy { padding: 0.75rem; margin: -0.75rem -0.75rem 1rem; }
|
||||
.hero-title { font-size: 2rem; }
|
||||
.hero-hook { color: #555; }
|
||||
.feature-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); }
|
||||
.feature-card { padding: 1rem; border: 1px solid #ddd; border-radius: 0.5rem; }
|
||||
.foundation-grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 1rem; }
|
||||
.foundation-card { padding: 0.75rem; border: 1px dashed #bbb; }
|
||||
.foundation-card-label { font-weight: 700; }
|
||||
.foundation-card-detail { margin: 0.25rem 0 0; color: #555; }
|
||||
.action-row { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||
.action-row [role="button"] { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 0.375rem; }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
strictPort: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "Repo root + nested Vite app in website/",
|
||||
"config": {
|
||||
"files": ["**/index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": [
|
||||
"website/index.html",
|
||||
"website/src/App.jsx",
|
||||
"website/src/main.jsx",
|
||||
"website/src/styles.css",
|
||||
"website/vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero title in the nested app's source JSX",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "website/src/App.jsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"appDir": "website",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
},
|
||||
"steer": {
|
||||
"message": "steer-e2e mark hero",
|
||||
"expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]",
|
||||
"expectSourceContains": "data-impeccable-steer=\"e2e\"",
|
||||
"sourceFile": "src/App.jsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
package-lock.json
|
||||
@@ -20,6 +20,7 @@
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"assertSourceContains": ["{item.title}"],
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
window.__impeccableStatefulMounts = (window.__impeccableStatefulMounts || 0) + 1;
|
||||
});
|
||||
|
||||
const CATALOG = [
|
||||
{ name: 'Design snack', amount: '$12' },
|
||||
{ name: 'Studio coffee', amount: '$8' },
|
||||
{ name: 'Type license', amount: '$44' },
|
||||
];
|
||||
|
||||
function addExpense() {
|
||||
expenses = [
|
||||
...expenses,
|
||||
{ id: expenses.length + 1, name: 'Design snack', amount: '$12' },
|
||||
];
|
||||
const next = CATALOG[expenses.length % CATALOG.length];
|
||||
expenses = [...expenses, { id: expenses.length + 1, ...next }];
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,10 +35,14 @@
|
||||
<p>Fügt die nächste gemeinsame Ausgabe hinzu, dann landet sie hier.</p>
|
||||
</article>
|
||||
{:else}
|
||||
<article class="expense-row" data-testid="expense-row">
|
||||
<strong>{expenses[0].name}</strong>
|
||||
<span>{expenses[0].amount}</span>
|
||||
</article>
|
||||
<ul class="expense-list" data-testid="expense-list">
|
||||
{#each expenses as expense, i}
|
||||
<li class="expense-row" data-testid="expense-row" data-index={i}>
|
||||
<strong class="expense-name">{expense.name}</strong>
|
||||
<span class="expense-amount">{expense.amount}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -96,18 +104,33 @@
|
||||
color: #1b3329;
|
||||
}
|
||||
|
||||
.empty-card,
|
||||
.expense-row {
|
||||
.empty-card {
|
||||
border: 1px solid #16332b;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
/* The padding is what the E2E picker aims at: a container whose centre is
|
||||
covered by a child can never be hovered, so the list keeps a band of its
|
||||
own around the rows. */
|
||||
.expense-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
list-style: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expense-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid #16332b;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.detect-target {
|
||||
|
||||
@@ -16,11 +16,57 @@
|
||||
"expectedPreviewMode": "svelte-component"
|
||||
},
|
||||
{
|
||||
"name": "wraps stateful expense row through Svelte component preview",
|
||||
"args": { "classes": "expense-row", "tag": "article" },
|
||||
"name": "wraps the each-block list through Svelte component preview",
|
||||
"args": { "classes": "expense-list", "tag": "ul" },
|
||||
"expectedFile": "node_modules/.impeccable-live/wraptest1/manifest.json",
|
||||
"expectedSourceFile": "src/routes/+page.svelte",
|
||||
"expectedPreviewMode": "svelte-component"
|
||||
}
|
||||
]
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"steer": false,
|
||||
"pickSelector": "ul.expense-list",
|
||||
"pickPosition": { "x": 10, "y": 10 },
|
||||
"variantSequence": [3, 1, 2],
|
||||
"acceptedSourcePattern": "<ul[^>]*class=\"[^\"]*\\bexpense-list\\b",
|
||||
"assertSourceContains": [
|
||||
"{#each expenses as expense, i}",
|
||||
"{expense.name}",
|
||||
"{expense.amount}"
|
||||
],
|
||||
"preActions": [
|
||||
{ "type": "click", "selector": "[data-testid='add-expense']" },
|
||||
{ "type": "wait", "selector": "[data-testid='expense-row'][data-index='0']" },
|
||||
{ "type": "click", "selector": "[data-testid='add-expense']" },
|
||||
{ "type": "wait", "selector": "[data-testid='expense-row'][data-index='1']" },
|
||||
{ "type": "click", "selector": "[data-testid='add-expense']" },
|
||||
{ "type": "wait", "selector": "[data-testid='expense-row'][data-index='2']" }
|
||||
],
|
||||
"stateProbe": {
|
||||
"textSelector": "[data-testid='open-count']",
|
||||
"expectedText": "3 offen",
|
||||
"windowProperty": "__impeccableStatefulMounts",
|
||||
"expectedWindowValue": 1,
|
||||
"expectWindowUnchanged": true
|
||||
},
|
||||
"paramsScenario": {
|
||||
"variant": 2,
|
||||
"rangeLabel": "Lead",
|
||||
"rangeValue": 1.8,
|
||||
"stepsLabel": "Density",
|
||||
"stepsOptionLabel": "Snug",
|
||||
"expectSourceContains": ["line-height: 1.8", "letter-spacing: 0.01em"],
|
||||
"expectSourceMissing": ["letter-spacing: 0.14em"]
|
||||
},
|
||||
"componentFailureScenarios": { "variant": 2, "storageLoss": false },
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"componentFailureScenarios": { "variant": 2 },
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { compile } from 'svelte/compiler';
|
||||
import {
|
||||
bakeParamValues,
|
||||
collectAllSelectors,
|
||||
normalizeSelector,
|
||||
parseStylesheet,
|
||||
pruneUnusedSelectors,
|
||||
reconcileCss,
|
||||
serializeNodes,
|
||||
splitSelectorList,
|
||||
stripParamSelector,
|
||||
substituteParamVar,
|
||||
} from '../skill/scripts/live/accept-css.mjs';
|
||||
import { verifyAcceptedSource } from '../skill/scripts/live/accept-verify.mjs';
|
||||
|
||||
describe('accept-time CSS reconciliation', () => {
|
||||
it('parses rules, at-blocks, and comments with stable round-trip', () => {
|
||||
const css = `/* note */\n.a { color: red; }\n@media (min-width: 600px) {\n .a { color: blue; }\n .b { margin: 0; }\n}\n@font-face { font-family: X; src: url("a{b}.woff"); }`;
|
||||
const nodes = parseStylesheet(css);
|
||||
assert.deepEqual(nodes.map((n) => n.type), ['comment', 'rule', 'at', 'at']);
|
||||
assert.equal(nodes[2].children.length, 2);
|
||||
const out = serializeNodes(nodes);
|
||||
assert.match(out, /@media \(min-width: 600px\)/);
|
||||
assert.match(out, /a\{b\}\.woff/);
|
||||
});
|
||||
|
||||
it('replaces matching selectors instead of appending duplicates', () => {
|
||||
const existing = `.pit-board { border-top: 1px solid #333; padding: 8px; }\n.pit-board .label { color: gray; }`;
|
||||
const variant = `.pit-board { padding: 12px; clip-path: polygon(0 0); }\n.pit-board .arrow { width: 10px; }`;
|
||||
const { css, replaced, appended } = reconcileCss(existing, variant);
|
||||
assert.equal(replaced, 1);
|
||||
assert.equal(appended, 1);
|
||||
// The superseded divider border is gone; the new body wins.
|
||||
assert.doesNotMatch(css, /border-top/);
|
||||
assert.match(css, /clip-path/);
|
||||
assert.match(css, /\.pit-board \.label/);
|
||||
assert.match(css, /\.pit-board \.arrow/);
|
||||
// Exactly one .pit-board rule remains.
|
||||
assert.equal(css.split('.pit-board {').length - 1, 1);
|
||||
});
|
||||
|
||||
it('merges inside matching media queries', () => {
|
||||
const existing = `@media (max-width: 700px) { .row { gap: 4px; } }`;
|
||||
const variant = `@media (max-width: 700px) { .row { gap: 8px; } .col { gap: 2px; } }`;
|
||||
const { css } = reconcileCss(existing, variant);
|
||||
assert.match(css, /gap: 8px/);
|
||||
assert.doesNotMatch(css, /gap: 4px/);
|
||||
assert.match(css, /\.col \{ gap: 2px; \}/);
|
||||
assert.equal(css.split('@media').length - 1, 1);
|
||||
});
|
||||
|
||||
it('normalizes selectors for matching', () => {
|
||||
assert.equal(normalizeSelector('.a > .b, .c'), '.a>.b,.c');
|
||||
assert.deepEqual(splitSelectorList('.a[data-x="1,2"], .b:is(.c, .d)'), ['.a[data-x="1,2"]', '.b:is(.c, .d)']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('param baking', () => {
|
||||
it('substitutes range vars with paren-aware fallbacks', () => {
|
||||
const css = `.x { width: calc(var(--p-depth, calc(2px * 3)) + 1px); opacity: var(--p-depth); }`;
|
||||
const out = substituteParamVar(css, 'depth', '10px');
|
||||
assert.equal(out, `.x { width: calc(10px + 1px); opacity: 10px; }`);
|
||||
});
|
||||
|
||||
it('keeps only the chosen steps branch and strips the attribute selector', () => {
|
||||
const css = [
|
||||
`:global([data-p-density="airy"]) .grid { gap: 24px; }`,
|
||||
`:global([data-p-density="snug"]) .grid { gap: 8px; }`,
|
||||
`.grid { display: grid; }`,
|
||||
].join('\n');
|
||||
const out = bakeParamValues(css, [
|
||||
{ id: 'density', kind: 'steps', default: 'airy' },
|
||||
], { density: 'snug' });
|
||||
assert.doesNotMatch(out, /24px/);
|
||||
assert.doesNotMatch(out, /data-p-density/);
|
||||
assert.match(out, /\.grid \{ gap: 8px; \}/);
|
||||
assert.match(out, /\.grid \{ display: grid; \}/);
|
||||
});
|
||||
|
||||
it('normalizes toggle booleans to 0/1 and resolves presence selectors', () => {
|
||||
const css = `.t { opacity: var(--p-serif, 0); }\n[data-p-serif] .t { font-family: serif; }`;
|
||||
const on = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: true });
|
||||
assert.match(on, /opacity: 1/);
|
||||
assert.match(on, /\.t \{ font-family: serif; \}/);
|
||||
const off = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: false });
|
||||
assert.match(off, /opacity: 0/);
|
||||
assert.doesNotMatch(off, /font-family: serif/);
|
||||
});
|
||||
|
||||
it('falls back to declared defaults when no value was sent', () => {
|
||||
const css = `.x { gap: var(--p-gap, 4px); }`;
|
||||
const out = bakeParamValues(css, [{ id: 'gap', kind: 'range', default: 12 }], {});
|
||||
assert.match(out, /gap: 12/);
|
||||
});
|
||||
|
||||
it('bakes undeclared sent values as ranges instead of ignoring them', () => {
|
||||
const css = `.x { gap: var(--p-mystery, 4px); }`;
|
||||
const out = bakeParamValues(css, [], { mystery: '9px' });
|
||||
assert.match(out, /gap: 9px/);
|
||||
});
|
||||
|
||||
it('drops rules whose bodies become empty and strips readiness sentinels', () => {
|
||||
const css = `.x { --impeccable-variant-ready: 1; }\n.y { color: red; }`;
|
||||
const out = bakeParamValues(css, [], {});
|
||||
assert.doesNotMatch(out, /impeccable-variant-ready/);
|
||||
assert.doesNotMatch(out, /\.x/);
|
||||
assert.match(out, /\.y/);
|
||||
});
|
||||
|
||||
it('stripParamSelector cleans emptied :global wrappers', () => {
|
||||
assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'x'), '.b');
|
||||
assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'y'), null);
|
||||
assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', true), '.root .b');
|
||||
assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', false), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compiler-driven pruning', () => {
|
||||
it('removes selectors svelte reports as unused', () => {
|
||||
const component = `<div class="kept"><span class="inner">hi</span></div>\n<style>\n .kept { color: red; }\n .gone { color: blue; }\n .kept .inner, .kept .missing { font-weight: bold; }\n @media (min-width: 600px) { .alsogone { padding: 4px; } .kept { margin: 0; } }\n</style>`;
|
||||
const { source, removed } = pruneUnusedSelectors(component, compile);
|
||||
assert.equal(removed.includes('.gone'), true);
|
||||
assert.equal(removed.includes('.alsogone'), true);
|
||||
assert.doesNotMatch(source, /color: blue/);
|
||||
assert.doesNotMatch(source, /\.alsogone/);
|
||||
assert.match(source, /\.kept \{ color: red; \}/);
|
||||
assert.match(source, /\.kept \.inner \{ font-weight: bold; \}/);
|
||||
assert.match(source, /\.kept \{ margin: 0; \}/);
|
||||
// The pruned result still compiles without unused-selector warnings.
|
||||
const { warnings } = compile(source, { generate: false });
|
||||
assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
|
||||
});
|
||||
|
||||
it('never throws on uncompilable input', () => {
|
||||
const { source } = pruneUnusedSelectors('<div>{broken', () => { throw new Error('nope'); });
|
||||
assert.equal(source, '<div>{broken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postcondition scanner', () => {
|
||||
it('flags every class of live-mode leftover with line numbers', () => {
|
||||
const dirty = [
|
||||
'<div data-impeccable-variant="2">x</div>',
|
||||
'<!-- impeccable-param-values abc: {"d":1} -->',
|
||||
'.x { width: var(--p-depth, 4px); }',
|
||||
'<section data-p-density="snug">y</section>',
|
||||
'<!-- impeccable-carbonize-start abc -->',
|
||||
].join('\n');
|
||||
const { clean, findings } = verifyAcceptedSource(dirty);
|
||||
assert.equal(clean, false);
|
||||
assert.equal(findings.length >= 5, true);
|
||||
assert.deepEqual(findings.map((f) => f.line).slice(0, 5), [1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('passes clean source', () => {
|
||||
const { clean } = verifyAcceptedSource('<div class="pit-board">ok</div>\n<style>.pit-board { gap: 8px; }</style>');
|
||||
assert.equal(clean, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: parser boundaries', () => {
|
||||
it('parses compact CSS with no whitespace between rules (B1)', () => {
|
||||
const nodes = parseStylesheet('.a{x:1}.b{y:2}.c{z:3}');
|
||||
assert.deepEqual(nodes.map((n) => n.prelude), ['.a', '.b', '.c']);
|
||||
});
|
||||
|
||||
it('accept-style reconcile survives a minified existing block (B1)', () => {
|
||||
const { css } = reconcileCss('.pit-board{display:flex}.stage{padding:8px}.footer{color:gray}', '.pit-board { display: grid; }');
|
||||
assert.match(css, /\.stage \{ padding:8px \}/);
|
||||
assert.match(css, /\.footer \{ color:gray \}/);
|
||||
assert.match(css, /display: grid/);
|
||||
assert.doesNotMatch(css, /display:flex/);
|
||||
});
|
||||
|
||||
it('block-less at-statements do not swallow the following rule (M4)', () => {
|
||||
const existing = '@import url("t.css");\n.a { color: red; border: 1px solid black; }\n.b { z-index: 1; }';
|
||||
const { css, replaced } = reconcileCss(existing, '.a { color: green; }');
|
||||
assert.equal(replaced, 1);
|
||||
assert.doesNotMatch(css, /border: 1px solid black/);
|
||||
assert.match(css, /color: green/);
|
||||
assert.match(css, /@import url\("t\.css"\);/);
|
||||
assert.match(css, /\.b \{ z-index: 1; \}/);
|
||||
assert.equal(css.split('.a {').length - 1, 1);
|
||||
});
|
||||
|
||||
it('keeps sibling declarations when stripping a one-line sentinel (m1)', () => {
|
||||
const out = bakeParamValues('.x { --impeccable-variant-ready: 1; color: red; padding: 4px; }', [], {});
|
||||
assert.match(out, /color: red/);
|
||||
assert.match(out, /padding: 4px/);
|
||||
assert.doesNotMatch(out, /impeccable-variant-ready/);
|
||||
});
|
||||
|
||||
it('collectAllSelectors sees rules at every nesting level', () => {
|
||||
const selectors = collectAllSelectors('.a { x: 1; }\n@media (min-width: 10px) { .b { y: 2; } @supports (display: grid) { .c { z: 3; } } }');
|
||||
assert.deepEqual([...selectors].sort(), ['.a', '.b', '.c']);
|
||||
});
|
||||
});
|
||||
@@ -1082,6 +1082,257 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('acknowledges every successful component mount back to the server', () => {
|
||||
// `arrivedVariants` counts what the agent published. Without this ack the
|
||||
// server has no way to tell "the user is comparing three variants" from
|
||||
// "three modules 404'd and the page is blank".
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/reportVariantMounted\(sessionId, variantNum, moduleUrl\);/,
|
||||
'a successful mount must post variant_mounted so the journal carries render truth',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function reportVariantMounted\([\s\S]{0,400}?sendEvent\(\{\s*type: 'variant_mounted',/,
|
||||
'variant_mounted must go through the normal event POST helper',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports mount failures to the server instead of only logging them', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/console\.error\('\[impeccable\] Failed to mount component variant[\s\S]{0,200}?reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);/,
|
||||
'event=live_browser.silent_mount_failure actor=browser operation=mount_component_variant risk=agent_never_learns_render_failed expected=variant_mount_failed posted actual=console.error only',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function reportVariantMountFailed\([\s\S]{0,900}?sendEvent\(\{ type: 'variant_mount_failed', id: sessionId, variant, url, error: message \}\);/,
|
||||
'variant_mount_failed must carry the failed module URL and the error text',
|
||||
);
|
||||
// Both the first mount and a variant switch route through this one catch,
|
||||
// so the switch path can no longer revert with zero feedback.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);[\s\S]{0,300}?showMountErrorCard\(sessionId, \{/,
|
||||
'a failed mount must raise the persistent error card from the shared catch',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session alive when a component variant fails to mount', () => {
|
||||
const abort = SOURCE.match(
|
||||
/function abortSvelteComponentInjection\(sessionId, details\) \{[\s\S]*?\n \}\n/,
|
||||
);
|
||||
assert.ok(abort, 'abortSvelteComponentInjection must still exist');
|
||||
const body = abort[0];
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/clearSession\(\)/,
|
||||
'event=live_browser.mount_failure_wipe actor=browser operation=abort_svelte_injection risk=durable_session_orphaned expected=localStorage preserved actual=clearSession() called',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/currentSessionId = null/,
|
||||
'the session id is the only handle Retry and a republish have; abort must not drop it',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/setLiveState\('PICKING'\)/,
|
||||
'a mount failure is not the end of the session, so the bar must not fall back to PICKING',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
body,
|
||||
/showToast\(/,
|
||||
'the 5s toast was replaced by a persistent card; a toast that vanishes reads as no feedback at all',
|
||||
);
|
||||
assert.match(body, /showMountErrorCard\(/, 'abort must raise the persistent error card');
|
||||
assert.match(body, /saveSession\(\)/, 'abort must keep the localStorage cache in step with the server');
|
||||
});
|
||||
|
||||
it('gives the manifest fetch failure and the session mismatch the same error card', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/if \(manifest\.id !== sessionId\) return;/,
|
||||
'event=live_browser.silent_manifest_mismatch actor=browser operation=inject_from_manifest risk=bar_stuck_in_generating expected=error card actual=bare return',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(manifest\.id !== sessionId\) \{[\s\S]{0,700}?showMountErrorCard\(sessionId, \{/,
|
||||
'a manifest belonging to another session must surface, not disappear',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/Failed to mount component-preview variants:[\s\S]{0,400}?reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, manifestPath, err\);/,
|
||||
'a manifest that cannot be read is a render failure the agent must hear about',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, url,/,
|
||||
'the /source fetch URL carries the live token and must never be journaled; report the manifest path',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a retry that re-runs injection for the same session', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function retryMountErrorCard\(\) \{[\s\S]{0,900}?injectSvelteComponentsFromManifest\(manifestPath, sessionId\);/,
|
||||
'the Retry button must re-enter the normal injection path rather than restarting the session',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function truncateMiddle\(value, max\)/,
|
||||
'the card shows the failed module URL truncated in the middle so both ends stay readable',
|
||||
);
|
||||
});
|
||||
|
||||
it('rehydrates from the server when localStorage has no session', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/function restoreSessionWithoutWrapper\(reason, activeSessions\) \{\s*const saved = loadSession\(\);/,
|
||||
'event=live_browser.storage_gated_restore actor=browser operation=sse_connected risk=durable_session_unreachable expected=server summary can seed a restore actual=localStorage is a gate',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const adopted = cached\?\.id \? null : findAdoptableServerSession\(activeSessions\);/,
|
||||
'a page with no cached session must be able to adopt the durable one the server reports',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?&& session\.pageUrl\s*&& pageMatchesCurrent\(session\.pageUrl\)/,
|
||||
'adoption must require an explicit pageUrl match so it cannot hijack an unrelated route',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?!isTerminalSessionSummary\(session\)/,
|
||||
'accepted, discarded, and completed sessions must never be adopted',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries no agent phase the server cannot emit', () => {
|
||||
// Every one of these lived in PHASE_RANK and, for most, in a status string
|
||||
// the user could never see: recordAgentPhase() in live-server.mjs has
|
||||
// never emitted them. The validator now rejects them outright, so a
|
||||
// leftover branch here is a branch that cannot run.
|
||||
for (const retired of [
|
||||
'first_variant_generating',
|
||||
'first_variant_validating',
|
||||
'remaining_variants_generating',
|
||||
'remaining_variants_validating',
|
||||
'variant_parameters_generating',
|
||||
'variant_parameters_validating',
|
||||
'parameters_ready',
|
||||
]) {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
new RegExp(retired),
|
||||
'event=live_browser.dead_agent_phase actor=browser operation=phase_rank risk=ui_branches_on_phase_nothing_sends phase=' + retired,
|
||||
);
|
||||
}
|
||||
// The phases the server does emit must all still rank.
|
||||
for (const live of [
|
||||
'picked_up', 'scaffolding', 'scaffold_fallback', 'source_ready',
|
||||
'generation_ready', 'first_reviewable', 'second_reviewable', 'all_variants_ready',
|
||||
]) {
|
||||
assert.match(SOURCE, new RegExp('\\n\\s+' + live + ': \\d+,'), live + ' must keep a rank');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the cycling counter through one function with one denominator', () => {
|
||||
// buildCyclingRow showed visibleVariant/expectedVariants while
|
||||
// syncCyclingControls wrote shown/arrivedVariants, so the same unchanged
|
||||
// state rendered as "2/3" or "2/2" depending on which path ran last.
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/visibleVariant \+ '\/' \+ expectedVariants/,
|
||||
'event=live_browser.counter_drift actor=browser operation=cycling_counter risk=two_denominators_for_one_counter',
|
||||
);
|
||||
assert.doesNotMatch(SOURCE, /shown \+ '\/' \+ arrivedVariants/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function cyclingCounterText\(\) \{[\s\S]{0,300}?arrivedVariants > 0 \? arrivedVariants : expectedVariants/,
|
||||
'the denominator policy is arrived-when-known, expected otherwise, in one place',
|
||||
);
|
||||
const counterAssignments = SOURCE.match(/-variant-counter'\)?;?[\s\S]{0,120}?textContent = ([^;]+);/g) || [];
|
||||
for (const assignment of counterAssignments) {
|
||||
assert.match(assignment, /cyclingCounterText\(\)/, 'every counter write goes through cyclingCounterText');
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the steer bar a visible Send control alongside Enter', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/pageChatSendBtn = el\('button'/,
|
||||
'event=live_browser.steer_send_affordance actor=user operation=steer risk=enter_only_submit_reads_as_dead_input',
|
||||
);
|
||||
assert.match(SOURCE, /pageChatSendBtn\.id = PREFIX \+ '-page-chat-send'/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function syncPageChatSendButton\(\)[\s\S]{0,900}?pageChatSendBtn\.disabled = !visible \|\| !hasText/,
|
||||
'Send is disabled with an empty input',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function syncPageChatSendButton\(\)[\s\S]{0,900}?const visible = !steerLocked/,
|
||||
'Send disappears while a steer is in flight',
|
||||
);
|
||||
// Enter must still submit.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/if \(e\.key === 'Enter'\) \{\s*e\.preventDefault\(\);\s*submitSteerMessage\(\);/,
|
||||
'keyboard submit stays',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/PREFIX \+ '-page-chat-send'\] \}/,
|
||||
'the Send control must be registered as live UI chrome so it is excluded from capture',
|
||||
);
|
||||
});
|
||||
|
||||
it('says a queued steer is queued instead of pulsing at the user', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function steerQueuedBehindGeneration\(\) \{[\s\S]{0,220}?steerLocked && !agentPollingConnected && agentHasWorkInFlight\(\)/,
|
||||
'event=live_browser.steer_queue_feedback actor=user operation=steer_during_generation risk=queued_request_reads_as_lost',
|
||||
);
|
||||
assert.match(SOURCE, /Queued behind current generation/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function syncSteerQueueHint\(\)[\s\S]{0,400}?pageChatDotsEl\.style\.display = 'none'/,
|
||||
'the queue hint replaces the bare dots rather than sitting beside them',
|
||||
);
|
||||
assert.match(SOURCE, /function syncAgentPollingUi\(connected\) \{[\s\S]{0,140}?syncSteerQueueHint\(\)/);
|
||||
});
|
||||
|
||||
it('names the actual cause when a steer times out', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/Check that live-poll is running and replies with steer_done/,
|
||||
'event=live_browser.steer_timeout_copy actor=user operation=steer_timeout risk=blames_live_poll_for_a_busy_agent',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function steerTimeoutMessage\(\)[\s\S]{0,900}?steerQueuedBehindGeneration\(\)[\s\S]{0,400}?!agentPollingConnected/,
|
||||
'the timeout message branches on agent-busy vs nobody-polling',
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes an empty DESIGN.md from a missing one in the design panel', () => {
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function designEmptyMessage\(\)[\s\S]{0,700}?designState\.hasMd && !designState\.hasSidecar[\s\S]{0,300}?DESIGN\.md found, no structured tokens to display/,
|
||||
'event=live_browser.design_empty_state actor=user operation=open_design_panel risk=present_design_md_reported_as_missing',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/const beforeCount = body\.childElementCount;/,
|
||||
'the empty check must ignore the stale hint and CTA the caller already appended',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/msgDiv\('empty', 'No design system data available\.'\)/,
|
||||
'the bare message may only survive as the genuinely-absent branch of designEmptyMessage',
|
||||
);
|
||||
});
|
||||
|
||||
it('editing focus timeout does not read a stale inline edit row', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
@@ -1094,4 +1345,21 @@ describe('live-browser.js regression guards', () => {
|
||||
'edit-mode delayed focus should capture the element before scheduling and no-op if editing ended before the timeout fires',
|
||||
);
|
||||
});
|
||||
it('adopts only variant comparisons from the server, never steer or manual sessions', () => {
|
||||
// A completed-steer session is non-terminal and carries a sourceFile;
|
||||
// adopting it as a comparison hunts for a variant wrapper that never
|
||||
// existed and wedges the bar before the user's next pick (found by the
|
||||
// vite8-react-base-path e2e after server-first rehydration landed).
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findAdoptableServerSession\([\s\S]{0,900}?Number\(session\.expectedVariants\) > 0/,
|
||||
'adoption must require a generation-shaped session (expectedVariants > 0)',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function findAdoptableServerSession\([\s\S]{0,900}?\^\(steer\|manual_edit\)/,
|
||||
'adoption must exclude steer and manual-edit phases',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+508
-87
@@ -22,11 +22,15 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createFakeAgent } from './live-e2e/agent.mjs';
|
||||
import {
|
||||
createFakeAgent,
|
||||
republishSvelteComponentVariants,
|
||||
FAKE_VARIANT_FONT_WEIGHTS,
|
||||
} from './live-e2e/agent.mjs';
|
||||
import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs';
|
||||
import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
|
||||
import {
|
||||
@@ -34,11 +38,14 @@ import {
|
||||
assertApplyDockLoading,
|
||||
assertAnnotationUploadEvent,
|
||||
assertSourceApplied,
|
||||
chooseTuneStep,
|
||||
clickExitLiveMode,
|
||||
cycleToVariant,
|
||||
clickAccept,
|
||||
clickApplyEdits,
|
||||
clickEditCopy,
|
||||
clickDiscard,
|
||||
clickMountRetry,
|
||||
clickSaveEdit,
|
||||
clickGo,
|
||||
clickNext,
|
||||
@@ -47,11 +54,18 @@ import {
|
||||
drawAnnotationPinAndStroke,
|
||||
getVisibleVariant,
|
||||
installLiveQueryHelpers,
|
||||
isMountErrorCardVisible,
|
||||
openTunePanel,
|
||||
pickElement,
|
||||
readComputedFontWeight,
|
||||
runLiveChromeBottomBarSmoke,
|
||||
setTuneRange,
|
||||
waitForApplyDockHidden,
|
||||
waitForBarHidden,
|
||||
waitForComputedFontWeight,
|
||||
waitForCycling,
|
||||
waitForMountErrorCard,
|
||||
waitForMountErrorCardGone,
|
||||
runInsertFlow,
|
||||
waitForHandshake,
|
||||
} from './live-e2e/ui.mjs';
|
||||
@@ -130,6 +144,33 @@ function shouldRunScenario(name) {
|
||||
return scenarioNames.size === 0 || scenarioNames.has('all') || scenarioNames.has(name);
|
||||
}
|
||||
|
||||
// Anything served out of the live preview / runtime tree. A 404 here is never
|
||||
// benign: it means the variant preview module or its runtime never loaded, and
|
||||
// the flow silently fell back to the untouched original.
|
||||
const LIVE_TREE_URL_RE = /\.impeccable-live|impeccable\/live\/preview/i;
|
||||
|
||||
// The two framework dev-mode notices every React fixture emits.
|
||||
const FRAMEWORK_NOISE_RE = /Download the React DevTools|StrictMode/i;
|
||||
|
||||
// Chromium's resource-failure console line. Only the favicon flavour is
|
||||
// allowlisted; a favicon is not part of any fixture and its absence proves
|
||||
// nothing about live mode.
|
||||
const RESOURCE_404_RE = /Failed to load resource: the server responded with a status of 404/i;
|
||||
const FAVICON_URL_RE = /favicon(\.[a-z0-9]+)?(\?|$)|\/favicon/i;
|
||||
|
||||
function isLiveTreeUrl(url) {
|
||||
return LIVE_TREE_URL_RE.test(String(url || ''));
|
||||
}
|
||||
|
||||
function isBenignConsoleError(entry) {
|
||||
const text = String(entry || '');
|
||||
// Live-tree failures are never allowlisted, whatever else they match.
|
||||
if (LIVE_TREE_URL_RE.test(text)) return false;
|
||||
if (FRAMEWORK_NOISE_RE.test(text)) return true;
|
||||
if (RESOURCE_404_RE.test(text)) return FAVICON_URL_RE.test(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (fixtures.length === 0) return;
|
||||
try {
|
||||
@@ -216,7 +257,11 @@ for (const { name, fixture } of fixtures) {
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
|
||||
const { page, tmp, consoleErrors, teardown } = session;
|
||||
// `tmp` is the staged repo root; `appRoot` is the directory the dev
|
||||
// server serves. They differ only for fixtures declaring
|
||||
// `runtime.appDir`. Every fixture-relative source path resolves against
|
||||
// appRoot — the repo root may not contain a single source file.
|
||||
const { page, tmp, appRoot, consoleErrors, teardown } = session;
|
||||
const expectedCount = 3;
|
||||
const isInsert = fixture.runtime.mode === 'insert';
|
||||
const insertCfg = fixture.runtime.insert || {};
|
||||
@@ -228,15 +273,25 @@ for (const { name, fixture } of fixtures) {
|
||||
? insertDomSelector
|
||||
: pickSelector;
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
|
||||
const variantContentSelector = isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
|
||||
// Component previews mount every variant into the same node, so one
|
||||
// selector serves all three; the HTML/JSX paths keep a div per variant.
|
||||
const variantContentSelectorFor = (variantNum) => (isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : `[data-impeccable-variant="${variantNum}"] .inserted-copy`)
|
||||
: usesSvelteComponentPreview
|
||||
? pickSelector
|
||||
: '[data-impeccable-variant="2"] > :first-child';
|
||||
: `[data-impeccable-variant="${variantNum}"] > :first-child`);
|
||||
let stateProbeBaseline = null;
|
||||
let sourceFile = null;
|
||||
|
||||
try {
|
||||
// 0. Root resolution — only for fixtures whose app is not the repo
|
||||
// root. live.mjs booted from the repo root and had to find the app
|
||||
// on its own; everything after this depends on it having done so.
|
||||
if (session.appDir) {
|
||||
t.diagnostic(`Asserting resolved roots for nested app ${session.appDir}/`);
|
||||
assertNestedAppRoots(session);
|
||||
}
|
||||
|
||||
// 1. Handshake
|
||||
t.diagnostic('Waiting for live handshake');
|
||||
await waitForHandshake(page);
|
||||
@@ -255,7 +310,7 @@ for (const { name, fixture } of fixtures) {
|
||||
const steerTimeouts = agentMode === 'llm'
|
||||
? { unlockTimeoutMs: 90_000, selectorTimeoutMs: 45_000, runPreActions }
|
||||
: { runPreActions };
|
||||
await runSteerSmoke(page, tmp, fixture, (m) => t.diagnostic(m), steerTimeouts);
|
||||
await runSteerSmoke(page, appRoot, fixture, (m) => t.diagnostic(m), steerTimeouts);
|
||||
}
|
||||
|
||||
// 2. preActions — fixtures with hidden/conditional content (modals,
|
||||
@@ -278,7 +333,7 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
} else {
|
||||
t.diagnostic(`Picking ${pickSelector}`);
|
||||
await pickElement(page, pickSelector);
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
|
||||
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
||||
const barText = await page.evaluate(() => {
|
||||
@@ -317,14 +372,20 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
|
||||
// 5. Source-side check: wrapper + style + variants are present
|
||||
sourceFile = await locateSessionFile(tmp);
|
||||
sourceFile = await locateSessionFile(appRoot);
|
||||
if (session.appDir) {
|
||||
assert.ok(
|
||||
relative(tmp, sourceFile).startsWith(session.appDir + '/'),
|
||||
`session source must live under ${session.appDir}/, got ${relative(tmp, sourceFile)}`,
|
||||
);
|
||||
}
|
||||
const after = readFileSync(sourceFile, 'utf-8');
|
||||
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
|
||||
if (svelteComponentSession) {
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantFile = join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantBody = readFileSync(variantFile, 'utf-8');
|
||||
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
const routeBody = readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
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');
|
||||
@@ -351,14 +412,14 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
if (insertCfg.assertAnchorContains) {
|
||||
const anchorSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8')
|
||||
? readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8')
|
||||
: after;
|
||||
assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched');
|
||||
}
|
||||
}
|
||||
if (svelteComponentSession) {
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
assert.match(readFileSync(join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
} else if (sourceFile.endsWith('.astro')) {
|
||||
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
|
||||
assert.match(
|
||||
@@ -379,7 +440,7 @@ for (const { name, fixture } of fixtures) {
|
||||
// emit no params per the live.md spec ("variants are fixed points").
|
||||
if (agentMode === 'fake') {
|
||||
const paramsSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'params.json'), 'utf-8')
|
||||
? readFileSync(join(appRoot, svelteComponentSession.manifest.componentDir, 'params.json'), 'utf-8')
|
||||
: after;
|
||||
assert.match(paramsSource, svelteComponentSession ? /"1"\s*:/ : /data-impeccable-params=/, 'params manifest emitted');
|
||||
for (const kind of ['range', 'steps', 'toggle']) {
|
||||
@@ -400,75 +461,64 @@ for (const { name, fixture } of fixtures) {
|
||||
? fixture.runtime.variantSequence
|
||||
: [2];
|
||||
let visible = await readVisibleVariantForCycle(page);
|
||||
let checkedVariantTwoStyle = false;
|
||||
for (const targetVariant of cycleSequence) {
|
||||
t.diagnostic(`Cycling to variant ${targetVariant}`);
|
||||
let cycleAttempts = 0;
|
||||
while (visible !== targetVariant) {
|
||||
if (cycleAttempts++ > expectedCount + 6) {
|
||||
throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`);
|
||||
}
|
||||
if (visible == null || visible < targetVariant) await clickNext(page);
|
||||
else await clickPrev(page);
|
||||
visible = await readVisibleVariantForCycle(page);
|
||||
}
|
||||
assert.equal(visible, targetVariant, `variant ${targetVariant} visible`);
|
||||
if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) {
|
||||
await page.waitForFunction((sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el && getComputedStyle(el).fontWeight === '900';
|
||||
}, variantContentSelector, { timeout: 5_000 }).catch(() => {});
|
||||
const variantWeight = await evaluatePageWithTimeout(
|
||||
// Render proof, not bar-label proof: the fake agent gives every variant
|
||||
// a distinct font-weight, so a computed read says which variant the
|
||||
// user is actually looking at. Checked for each variant the run reaches.
|
||||
const styleCheckedVariants = new Set();
|
||||
const assertVisibleVariantStyle = async (variantNum) => {
|
||||
if (agentMode !== 'fake') return;
|
||||
const expected = FAKE_VARIANT_FONT_WEIGHTS[variantNum];
|
||||
if (!expected || styleCheckedVariants.has(variantNum)) return;
|
||||
styleCheckedVariants.add(variantNum);
|
||||
const selector = variantContentSelectorFor(variantNum);
|
||||
await waitForComputedFontWeight(page, selector, expected, { timeout: 5_000 }).catch(() => {});
|
||||
const variantWeight = await readComputedFontWeight(page, selector).catch(() => null);
|
||||
if (variantWeight !== expected) {
|
||||
const styleSnapshot = await evaluatePageWithTimeout(
|
||||
page,
|
||||
(sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el ? getComputedStyle(el).fontWeight : null;
|
||||
},
|
||||
variantContentSelector,
|
||||
5_000,
|
||||
'variant font-weight read',
|
||||
);
|
||||
if (variantWeight !== '900') {
|
||||
const styleSnapshot = await evaluatePageWithTimeout(
|
||||
page,
|
||||
(sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
const styleEl = document.querySelector('style[data-impeccable-css]');
|
||||
const rules = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
if (sheet.ownerNode !== styleEl) continue;
|
||||
try {
|
||||
rules.push(...[...sheet.cssRules].map((rule) => rule.cssText));
|
||||
} catch (err) {
|
||||
rules.push(`cssRules error: ${err.message}`);
|
||||
}
|
||||
const styleEl = document.querySelector('style[data-impeccable-css]');
|
||||
const rules = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
if (sheet.ownerNode !== styleEl) continue;
|
||||
try {
|
||||
rules.push(...[...sheet.cssRules].map((rule) => rule.cssText));
|
||||
} catch (err) {
|
||||
rules.push(`cssRules error: ${err.message}`);
|
||||
}
|
||||
return {
|
||||
selector: sel,
|
||||
element: el?.outerHTML || null,
|
||||
parent: el?.parentElement?.outerHTML?.slice(0, 800) || null,
|
||||
computedWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
styleText: styleEl?.textContent || null,
|
||||
rules,
|
||||
};
|
||||
},
|
||||
variantContentSelector,
|
||||
5_000,
|
||||
'variant style snapshot',
|
||||
).catch((err) => ({ error: err.message }));
|
||||
t.diagnostic('--- variant style snapshot ---');
|
||||
t.diagnostic(JSON.stringify(styleSnapshot, null, 2));
|
||||
}
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
'900',
|
||||
'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape',
|
||||
);
|
||||
checkedVariantTwoStyle = true;
|
||||
}
|
||||
return {
|
||||
selector: sel,
|
||||
element: el?.outerHTML || null,
|
||||
parent: el?.parentElement?.outerHTML?.slice(0, 800) || null,
|
||||
computedWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
styleText: styleEl?.textContent || null,
|
||||
rules,
|
||||
};
|
||||
},
|
||||
selector,
|
||||
5_000,
|
||||
'variant style snapshot',
|
||||
).catch((err) => ({ error: err.message }));
|
||||
t.diagnostic(`--- variant ${variantNum} style snapshot ---`);
|
||||
t.diagnostic(JSON.stringify(styleSnapshot, null, 2));
|
||||
}
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
expected,
|
||||
`event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant variant=${variantNum} risk=unstyled_live_preview expected=font-weight ${expected} actual=${variantWeight} suggestion=inspect live CSS style mode and selector shape`,
|
||||
);
|
||||
};
|
||||
await assertVisibleVariantStyle(visible);
|
||||
for (const targetVariant of cycleSequence) {
|
||||
t.diagnostic(`Cycling to variant ${targetVariant}`);
|
||||
visible = await cycleToVariant(page, targetVariant, expectedCount, {
|
||||
settleTimeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(visible, targetVariant, `variant ${targetVariant} visible`);
|
||||
await assertVisibleVariantStyle(targetVariant);
|
||||
}
|
||||
|
||||
if (reloadVariants && usesSvelteComponentPreview) {
|
||||
@@ -554,11 +604,11 @@ for (const { name, fixture } of fixtures) {
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
if (svelteComponentTarget) {
|
||||
assert.equal(existsSync(svelteComponentTarget.manifestPath), false, 'Svelte temp preview session removed after accept');
|
||||
const snapshotPath = join(tmp, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`);
|
||||
const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`);
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
assert.equal(snapshot.phase, 'completed');
|
||||
assert.equal(snapshot.sourceFile, svelteComponentTarget.manifest.sourceFile);
|
||||
assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live/);
|
||||
assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live|\.impeccable\/live\/previews/);
|
||||
}
|
||||
assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed');
|
||||
assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed');
|
||||
@@ -627,11 +677,22 @@ for (const { name, fixture } of fixtures) {
|
||||
await page.waitForSelector(expectSelector, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// 9c. Network hygiene — nothing under the live preview/runtime tree
|
||||
// may 404 or fail. A missing `.impeccable-live` module means the
|
||||
// preview never mounted, which the DOM assertions above can miss
|
||||
// when the original element still renders.
|
||||
const liveTreeFailures = (session.failedRequests || []).filter((f) => isLiveTreeUrl(f.url));
|
||||
assert.equal(
|
||||
liveTreeFailures.length,
|
||||
0,
|
||||
`live preview/runtime requests must all succeed, got:\n${
|
||||
liveTreeFailures.map((f) => `${f.reason} ${f.url}`).join('\n')
|
||||
}`,
|
||||
);
|
||||
|
||||
// 10. Console hygiene — no errors during the whole flow.
|
||||
if (fixture.runtime.probe?.expectConsoleClean) {
|
||||
const realErrors = consoleErrors.filter((e) =>
|
||||
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(e),
|
||||
);
|
||||
const realErrors = consoleErrors.filter((e) => !isBenignConsoleError(e));
|
||||
if (realErrors.length > 0) {
|
||||
t.diagnostic('--- console errors ---');
|
||||
for (const e of realErrors) t.diagnostic(e);
|
||||
@@ -667,6 +728,201 @@ for (const { name, fixture } of fixtures) {
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Params end-to-end: knob → accept → baked literal in source.
|
||||
//
|
||||
// The Svelte accept pipeline bakes params mechanically, so the unit
|
||||
// tests already cover the transform. What they cannot cover is that the
|
||||
// value the user actually dialled in the Tune popover is the value that
|
||||
// reaches it. This drives the real controls and reads the real source.
|
||||
// -----------------------------------------------------------------
|
||||
if (shouldRunScenario('params') && fixture.runtime.paramsScenario) {
|
||||
it('bakes tuned param values into the accepted source', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const scenario = fixture.runtime.paramsScenario;
|
||||
const targetVariant = scenario.variant || 2;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session, sourceFile, svelteComponentTarget } = run;
|
||||
const { page } = session;
|
||||
try {
|
||||
await cycleToVariant(page, targetVariant, 3);
|
||||
|
||||
t.diagnostic('Opening the Tune popover');
|
||||
await openTunePanel(page);
|
||||
const applied = await setTuneRange(page, scenario.rangeLabel, scenario.rangeValue);
|
||||
assert.equal(applied, scenario.rangeValue, 'range knob took the requested value');
|
||||
await chooseTuneStep(page, scenario.stepsLabel, scenario.stepsOptionLabel);
|
||||
|
||||
t.diagnostic(`Accepting variant ${targetVariant} with tuned params`);
|
||||
await clickAccept(page, { expectedVariant: targetVariant });
|
||||
await waitForBarHidden(page);
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
|
||||
for (const needle of scenario.expectSourceContains || []) {
|
||||
assert.ok(
|
||||
final.includes(needle),
|
||||
`accepted source should bake ${JSON.stringify(needle)}; got:\n${final}`,
|
||||
);
|
||||
}
|
||||
for (const needle of scenario.expectSourceMissing || []) {
|
||||
assert.equal(
|
||||
final.includes(needle),
|
||||
false,
|
||||
`accepted source should drop the unchosen branch ${JSON.stringify(needle)}; got:\n${final}`,
|
||||
);
|
||||
}
|
||||
// The generic contract, independent of this fixture's values: every
|
||||
// preview-only param hook is gone once the source is accepted.
|
||||
assert.doesNotMatch(final, /data-p-[A-Za-z0-9_-]+/, 'no data-p-* param attributes survive accept');
|
||||
assert.doesNotMatch(final, /var\(--p-/, 'no unbaked var(--p-*) survives accept');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Failure injection for component previews.
|
||||
// -----------------------------------------------------------------
|
||||
if (fixture.runtime.componentFailureScenarios) {
|
||||
const failure = fixture.runtime.componentFailureScenarios;
|
||||
const brokenVariant = failure.variant || 2;
|
||||
|
||||
if (shouldRunScenario('mount-failure')) {
|
||||
it('surfaces a broken variant without losing the session, and recovers on Retry', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
// Auto-repair off: this scenario is about what the USER sees and can
|
||||
// do when a publish is unrenderable. An agent that silently
|
||||
// republishes would hide exactly the surface under test.
|
||||
const run = await bootComponentScenario({
|
||||
t,
|
||||
name,
|
||||
fixture,
|
||||
expectedCount: 3,
|
||||
agent: createFakeAgent({ autoRepairMountFailures: false }),
|
||||
});
|
||||
const { session, svelteComponentTarget } = run;
|
||||
const { page, appRoot } = session;
|
||||
try {
|
||||
const sessionId = svelteComponentTarget.manifest.id;
|
||||
const variantPath = revisionVariantPath(appRoot, run.manifestPath, brokenVariant);
|
||||
const saved = readFileSync(variantPath, 'utf-8');
|
||||
// Corrupt rather than delete: a published variant that does not
|
||||
// compile is the failure an agent actually produces, and it keeps
|
||||
// the module resolvable so the recovery is about the content.
|
||||
t.diagnostic(`Corrupting published revision file ${relative(appRoot, variantPath)}`);
|
||||
writeFileSync(variantPath, '<div class="impeccable-broken-variant">{ not valid svelte\n', 'utf-8');
|
||||
|
||||
// One step forward onto the variant whose module is gone. The step
|
||||
// deliberately does not settle, so drive the click directly.
|
||||
t.diagnostic(`Stepping onto the broken variant ${brokenVariant}`);
|
||||
await clickNext(page);
|
||||
|
||||
const cardText = await waitForMountErrorCard(page, { variant: brokenVariant });
|
||||
assert.match(cardText, /Retry/, 'the mount-error card offers a Retry');
|
||||
|
||||
// The session must survive the failure: the old behaviour wiped
|
||||
// local state and orphaned a session the server still tracked.
|
||||
const barVisible = await page.evaluate(() => {
|
||||
const bar = window.__impeccableLiveQuery('#impeccable-live-bar');
|
||||
return Boolean(bar) && bar.style.display !== 'none';
|
||||
});
|
||||
assert.equal(barVisible, true, 'the cycling bar survives a mount failure');
|
||||
const stored = await readLiveSessionStorage(page);
|
||||
assert.equal(stored?.id, sessionId, 'the local session record survives a mount failure');
|
||||
|
||||
const events = await waitForJournalEvent(appRoot, sessionId, 'variant_mount_failed', 15_000);
|
||||
const failedEvent = events.at(-1);
|
||||
assert.equal(failedEvent.variant, brokenVariant, 'the journal names the variant that failed');
|
||||
assert.ok(failedEvent.error, 'the journal carries the mount error text');
|
||||
|
||||
// A failed step reverts to the variant that still renders, so the
|
||||
// comparison is usable rather than blank; Retry re-imports the
|
||||
// session, and the repaired variant is reachable again.
|
||||
t.diagnostic('Restoring the revision file and clicking Retry');
|
||||
writeFileSync(variantPath, saved, 'utf-8');
|
||||
await clickMountRetry(page);
|
||||
await waitForMountErrorCardGone(page);
|
||||
await cycleToVariant(page, brokenVariant, 3, { settleTimeout: 20_000 });
|
||||
await waitForComputedFontWeight(
|
||||
page,
|
||||
fixture.runtime.pickSelector || 'h1.hero-title',
|
||||
FAKE_VARIANT_FONT_WEIGHTS[brokenVariant],
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
assert.equal(await getVisibleVariant(page), brokenVariant, 'the repaired variant is the visible one');
|
||||
assert.equal(await isMountErrorCardVisible(page), false, 'the repaired mount raises no new error');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('republish')) {
|
||||
it('mounts republished variant content instead of a cached compile', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session } = run;
|
||||
const { page, appRoot } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await cycleToVariant(page, brokenVariant, 3);
|
||||
await waitForComputedFontWeight(page, pickSelector, FAKE_VARIANT_FONT_WEIGHTS[brokenVariant]);
|
||||
const beforeRevision = readManifest(run.manifestPath).revision;
|
||||
|
||||
t.diagnostic('Republishing every variant with new styling');
|
||||
await republishSvelteComponentVariants({
|
||||
tmp: appRoot,
|
||||
manifestFile: relative(appRoot, run.manifestPath),
|
||||
live: session.live,
|
||||
css: (variantNum, shape) => `${shape.rootSelector} { font-weight: ${100 * variantNum}; }`,
|
||||
});
|
||||
|
||||
// A stale transform cache would keep serving the previous compile;
|
||||
// the server-stamped revision dir is what makes the import path new.
|
||||
await waitForComputedFontWeight(page, pickSelector, String(100 * brokenVariant), { timeout: 20_000 });
|
||||
const afterRevision = readManifest(run.manifestPath).revision;
|
||||
assert.ok(
|
||||
afterRevision > beforeRevision,
|
||||
`republish must bump the preview revision (${beforeRevision} → ${afterRevision})`,
|
||||
);
|
||||
assert.equal(await isMountErrorCardVisible(page), false, 'a clean republish shows no mount error');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('storage-loss') && failure.storageLoss !== false) {
|
||||
it('rehydrates the comparison from the server after localStorage is cleared', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session } = run;
|
||||
const { page } = session;
|
||||
try {
|
||||
await cycleToVariant(page, brokenVariant, 3);
|
||||
assert.ok(await readLiveSessionStorage(page), 'a local session record exists before the wipe');
|
||||
|
||||
t.diagnostic('Clearing localStorage and reloading');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
assert.equal(await readLiveSessionStorage(page), null, 'the local session record really was wiped');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||
|
||||
// Nothing local is left: reaching CYCLING again can only come from
|
||||
// the server's durable session record.
|
||||
await waitForCycling(page, 3, { timeout: 30_000 });
|
||||
const restored = await readLiveSessionStorage(page);
|
||||
assert.equal(restored?.id, run.svelteComponentTarget.manifest.id, 'the adopted session keeps its id');
|
||||
assert.equal(restored?.expected, 3, 'the adopted session knows the variant count');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunScenario('missed-done') && fixture.runtime.missedDoneReloadScenario) {
|
||||
it('recovers when the preflight reload makes the browser miss the done broadcast', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
@@ -692,7 +948,7 @@ for (const { name, fixture } of fixtures) {
|
||||
atomicDelayMs: 2500,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, tmp, teardown } = session;
|
||||
const { page, appRoot, teardown } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
@@ -731,7 +987,7 @@ for (const { name, fixture } of fixtures) {
|
||||
// this stale copy, forcing the completion-driven fallback through
|
||||
// its retry path — a single no-retry read here strands the tab in
|
||||
// GENERATING forever.
|
||||
const scaffoldSourceFile = join(tmp, fixture.runtime.missedDoneReloadScenario.sourceFile);
|
||||
const scaffoldSourceFile = join(appRoot, fixture.runtime.missedDoneReloadScenario.sourceFile);
|
||||
const scaffoldOnlySource = readFileSync(scaffoldSourceFile, 'utf-8');
|
||||
let staleSourceServed = false;
|
||||
await page.context().route('**/source?token=*', (route) => {
|
||||
@@ -867,7 +1123,7 @@ for (const { name, fixture } of fixtures) {
|
||||
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
||||
assert.match(generateEvent.screenshotPath, /\.impeccable\/live\/annotations\//, 'annotation screenshot is stored under live annotations');
|
||||
|
||||
const sourceFile = await locateSessionFile(session.tmp);
|
||||
const sourceFile = await locateSessionFile(session.appRoot);
|
||||
const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
|
||||
@@ -909,6 +1165,163 @@ for (const { name, fixture } of fixtures) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The params and failure-injection scenarios assert on deterministic variant
|
||||
* content (exact CSS values, exact font weights). An LLM agent legitimately
|
||||
* produces neither, so they only run against the fake agent.
|
||||
*/
|
||||
function canRunFakeAgentScenario(t) {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return false;
|
||||
}
|
||||
if ((process.env.IMPECCABLE_E2E_AGENT || 'fake') !== 'fake') {
|
||||
t.skip('scenario asserts on deterministic fake-agent output');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a fixture straight to CYCLING on a component-preview session and hand
|
||||
* back the handles the scenarios need: the manifest, the route source, and the
|
||||
* session itself. Callers own teardown.
|
||||
*/
|
||||
async function bootComponentScenario({ t, name, fixture, expectedCount = 3, agent }) {
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: agent || createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
try {
|
||||
const { page, appRoot } = session;
|
||||
await waitForHandshake(page);
|
||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||
await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title', {
|
||||
position: fixture.runtime.pickPosition,
|
||||
});
|
||||
await clickGo(page);
|
||||
await waitForCyclingRobust(page, expectedCount, {
|
||||
agentMode: 'fake',
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const manifestPath = await locateSessionFile(appRoot);
|
||||
const svelteComponentTarget = svelteComponentTargetFor(manifestPath);
|
||||
assert.ok(svelteComponentTarget, `expected a component-preview session, got ${manifestPath}`);
|
||||
return {
|
||||
session,
|
||||
manifestPath,
|
||||
svelteComponentTarget,
|
||||
sourceFile: manifestPath,
|
||||
};
|
||||
} catch (err) {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(manifestPath) {
|
||||
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The file the browser actually imports for a variant: the server snapshots
|
||||
* every publish into a fresh `r<N>/` dir and points the manifest at it, so the
|
||||
* session dir's copy is not what a mount reads.
|
||||
*/
|
||||
function revisionVariantPath(appRoot, manifestPath, variantNum) {
|
||||
const manifest = readManifest(manifestPath);
|
||||
const dir = manifest.revisionDir || manifest.componentDir;
|
||||
assert.ok(dir, 'manifest carries a preview directory');
|
||||
const extension = manifest.componentExtension || 'svelte';
|
||||
return join(appRoot, dir, `v${variantNum}.${extension}`);
|
||||
}
|
||||
|
||||
/** Poll the session journal for events of a given type. */
|
||||
async function waitForJournalEvent(appRoot, sessionId, type, timeoutMs = 15_000) {
|
||||
const journalPath = join(appRoot, '.impeccable', 'live', 'sessions', `${sessionId}.jsonl`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let seen = [];
|
||||
while (Date.now() < deadline) {
|
||||
seen = readJournalEvents(journalPath).filter((event) => event?.type === type);
|
||||
if (seen.length > 0) return seen;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`no ${type} event in ${journalPath} after ${timeoutMs}ms; saw types: ${
|
||||
[...new Set(readJournalEvents(journalPath).map((e) => e?.type))].join(', ') || '(none)'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function readJournalEvents(journalPath) {
|
||||
if (!existsSync(journalPath)) return [];
|
||||
return readFileSync(journalPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line)?.event || null; } catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the roots live.mjs resolved for an `appDir` fixture.
|
||||
*
|
||||
* The boot ran from the staged repo root, which carries no dev-server config
|
||||
* of its own. Live mode has to pick the nested app, keep PRODUCT.md/DESIGN.md
|
||||
* discovery at the git root, and leave its session state under the app plus a
|
||||
* pointer at the repo root. A repo-root server.json means the session forked
|
||||
* into a second, empty project — the failure this fixture exists to catch.
|
||||
*/
|
||||
function assertNestedAppRoots(session) {
|
||||
const { tmp, appRoot, appDir, liveBoot } = session;
|
||||
assert.ok(liveBoot, 'appDir fixtures boot through live.mjs and keep its payload');
|
||||
assert.equal(liveBoot.ok, true, `live.mjs boot payload: ${JSON.stringify(liveBoot)}`);
|
||||
|
||||
const roots = liveBoot.roots || {};
|
||||
assert.ok(
|
||||
roots.appRoot && roots.appRoot.endsWith(`/${appDir}`),
|
||||
`roots.appRoot should end with /${appDir}, got ${roots.appRoot}`,
|
||||
);
|
||||
assertSamePath(roots.appRoot, appRoot, 'roots.appRoot');
|
||||
assertSamePath(roots.repoRoot, tmp, 'roots.repoRoot');
|
||||
assertSamePath(roots.contextRoot, tmp, 'roots.contextRoot');
|
||||
assertSamePath(liveBoot.projectRoot, appRoot, 'projectRoot');
|
||||
|
||||
assert.equal(liveBoot.hasProduct, true, 'PRODUCT.md at the repo root is read from the nested app');
|
||||
assert.equal(liveBoot.hasDesign, true, 'DESIGN.md at the repo root is read from the nested app');
|
||||
|
||||
assert.ok(
|
||||
existsSync(join(appRoot, '.impeccable/live/roots.json')),
|
||||
'the resolved manifest is persisted under the app',
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(join(appRoot, '.impeccable/live/server.json')),
|
||||
'the live server registers itself under the app',
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(join(tmp, '.impeccable/live/app-root.json')),
|
||||
'the repo root gets a pointer to the app',
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(tmp, '.impeccable/live/server.json')),
|
||||
false,
|
||||
'no live session state may be written at the repo root',
|
||||
);
|
||||
}
|
||||
|
||||
function assertSamePath(actual, expected, label) {
|
||||
const resolve = (p) => {
|
||||
try { return realpathSync(String(p)); } catch { return String(p); }
|
||||
};
|
||||
assert.equal(resolve(actual), resolve(expected), `${label}: ${actual} !== ${expected}`);
|
||||
}
|
||||
|
||||
function recordGenerateEvents(agent, events) {
|
||||
return {
|
||||
...agent,
|
||||
@@ -964,6 +1377,11 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error
|
||||
writeFileSync(join(dir, 'error.txt'), String(error?.stack || error?.message || error || ''), 'utf-8');
|
||||
writeFileSync(join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2), 'utf-8');
|
||||
writeFileSync(join(dir, 'console-errors.log'), (session.consoleErrors || []).join('\n'), 'utf-8');
|
||||
writeFileSync(
|
||||
join(dir, 'failed-requests.log'),
|
||||
(session.failedRequests || []).map((f) => `${f.reason} ${f.url}`).join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
writeFileSync(join(dir, 'dev-server.log'), session.dev?.log?.() || '', 'utf-8');
|
||||
writeCommandOutput(dir, 'git-status.txt', tmp, ['status', '--short']);
|
||||
writeCommandOutput(dir, 'git-diff.patch', tmp, ['diff', '--', '.']);
|
||||
@@ -983,6 +1401,7 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error
|
||||
for (const file of walkSources(tmp)) copyFileFromTmp(tmp, file, join(dir, 'sources'));
|
||||
copyDirIfExists(join(tmp, '.impeccable', 'live'), join(dir, 'impeccable-live'));
|
||||
copyDirIfExists(join(tmp, 'node_modules', '.impeccable-live'), join(dir, 'impeccable-live-preview'));
|
||||
copyDirIfExists(join(tmp, '.impeccable', 'live', 'previews'), join(dir, 'impeccable-live-previews'));
|
||||
|
||||
if (session.page) {
|
||||
const html = await withCaptureTimeout(session.page.content(), 5_000, 'page content').catch((err) => `capture failed: ${err.message}`);
|
||||
@@ -1176,7 +1595,7 @@ async function runManualScenarioActions(page, actions, { t, fixture, session, de
|
||||
}
|
||||
|
||||
async function runManualEditStage(page, stage, { t, fixture, session, agentMode, defaultSelector }) {
|
||||
const { tmp } = session;
|
||||
const tmp = session.appRoot;
|
||||
|
||||
if (stage.beforeManualEdit) {
|
||||
await runManualScenarioActions(page, stage.beforeManualEdit, {
|
||||
@@ -1323,7 +1742,7 @@ async function runAcceptedVariantCycle(page, { t, fixture, session, pickSelector
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible before manual scenario accept');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
const sourceFile = await locateSessionFile(session.tmp);
|
||||
const sourceFile = await locateSessionFile(session.appRoot);
|
||||
await waitForSourceClean(sourceFile, 20_000);
|
||||
await waitForBarHidden(page, { timeout: 10_000 }).catch(() => {});
|
||||
|
||||
@@ -1610,6 +2029,7 @@ function svelteComponentTargetFor(filePath) {
|
||||
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
const sep = pathSepFor(filePath);
|
||||
const markers = [
|
||||
`${sep}.impeccable${sep}live${sep}previews${sep}`,
|
||||
`${sep}node_modules${sep}.impeccable-live${sep}`,
|
||||
`${sep}src${sep}lib${sep}impeccable${sep}`,
|
||||
`${sep}app${sep}.impeccable-live${sep}`,
|
||||
@@ -1712,6 +2132,7 @@ async function locateSessionFile(tmp) {
|
||||
function walkComponentManifests(root) {
|
||||
const results = [];
|
||||
const stack = [
|
||||
join(root, '.impeccable/live/previews'),
|
||||
join(root, 'node_modules/.impeccable-live'),
|
||||
join(root, 'src/lib/impeccable'),
|
||||
join(root, 'app/.impeccable-live'),
|
||||
|
||||
+349
-7
@@ -89,13 +89,25 @@ export const STEER_MARKER_VALUE = 'e2e';
|
||||
* - first variant visible (no display:none), rest hidden by the agent caller
|
||||
* - inner content = single <h1> per variant
|
||||
*/
|
||||
export function createFakeAgent() {
|
||||
export function createFakeAgent({ autoRepairMountFailures = true } = {}) {
|
||||
return {
|
||||
// Read by runAgentLoop's `variant_mount_failed` handler. Scenarios that
|
||||
// assert on the persistent mount-error card turn this off so the agent
|
||||
// does not republish the session out from under them.
|
||||
autoRepairMountFailures,
|
||||
|
||||
/** @type {LiveAgent['generateVariants']} */
|
||||
async generateVariants(event, context = {}) {
|
||||
if (event.mode === 'insert') {
|
||||
return generateInsertFakeVariants(context);
|
||||
}
|
||||
// Contract-v2 Svelte component previews get their own author path: the
|
||||
// scaffolder already wrote stubs whose control flow and prop references
|
||||
// are correct, so the only honest thing a variant can change is style.
|
||||
if (context.wrapInfo?.previewMode === 'svelte-component') {
|
||||
const svelteOutput = await generateSvelteComponentFakeVariants(event, context);
|
||||
if (svelteOutput) return svelteOutput;
|
||||
}
|
||||
const text = event.element?.textContent?.trim() || extractText(event.element?.outerHTML) || 'Title';
|
||||
const tag = (event.element?.tagName || 'h1').toLowerCase();
|
||||
const cls = (event.element?.classes || ['hero-title'])
|
||||
@@ -105,7 +117,14 @@ export function createFakeAgent() {
|
||||
const preservedAttrs = buildPreservedVariantAttrs(event.element || {}, cls);
|
||||
const elementOpen = `<${tag}${preservedAttrs}>`;
|
||||
const elementClose = `</${tag}>`;
|
||||
const variantHtml = `${elementOpen}${htmlEscape(text)}${elementClose}`;
|
||||
// The JSX source may bind its text through an expression (`{item.title}`
|
||||
// inside a `.map()`). The DOM only ever hands the agent the rendered
|
||||
// string, so writing that back would freeze one item's text into the
|
||||
// template for every item. The Svelte path already solves this with a
|
||||
// prop contract; on the source-preview path the equivalent is to carry
|
||||
// the original expression through untouched.
|
||||
const sourceExprInner = await readJsxExpressionInner(context);
|
||||
const variantHtml = `${elementOpen}${sourceExprInner ?? htmlEscape(text)}${elementClose}`;
|
||||
const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed';
|
||||
|
||||
// Variant 1 — red color, with a `range` param tuning hue lightness.
|
||||
@@ -158,20 +177,25 @@ export function createFakeAgent() {
|
||||
// Scoped CSS for most frameworks. Astro component styles are transformed
|
||||
// and scoped by the compiler, so live preview CSS must use a global style
|
||||
// tag plus explicit variant prefixes instead of raw @scope rules.
|
||||
// Each variant carries a distinct font-weight so the E2E suite can prove
|
||||
// which variant is actually rendered, not merely which one the bar says
|
||||
// is selected. Keep these in sync with FAKE_VARIANT_FONT_WEIGHTS.
|
||||
const scopedCss = useAstroGlobalCss
|
||||
? [
|
||||
`[data-impeccable-variant="1"] > ${tag} {`,
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
'}',
|
||||
`[data-impeccable-variant="2"] > ${tag} { font-weight: 900; }`,
|
||||
`[data-impeccable-variant="2"][data-p-face="serif"] > ${tag} { font-family: ui-serif, serif; }`,
|
||||
`[data-impeccable-variant="2"][data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
|
||||
`[data-impeccable-variant="3"] > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
`[data-impeccable-variant="3"] > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
`[data-impeccable-variant="3"][data-p-italic] > ${tag} { font-style: italic; }`,
|
||||
].join('\n')
|
||||
: [
|
||||
'@scope ([data-impeccable-variant="1"]) {',
|
||||
` :scope > ${tag} {`,
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
' }',
|
||||
'}',
|
||||
@@ -181,7 +205,7 @@ export function createFakeAgent() {
|
||||
` :scope[data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
|
||||
'}',
|
||||
'@scope ([data-impeccable-variant="3"]) {',
|
||||
` :scope > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
` :scope > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
` :scope[data-p-italic] > ${tag} { font-style: italic; }`,
|
||||
'}',
|
||||
].join('\n');
|
||||
@@ -256,17 +280,19 @@ function generateInsertFakeVariants(context = {}) {
|
||||
const scopedCss = useAstroGlobalCss
|
||||
? [
|
||||
'[data-impeccable-variant="1"] .inserted-copy {',
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
'}',
|
||||
'[data-impeccable-variant="2"] .inserted-copy { font-weight: 900; }',
|
||||
'[data-impeccable-variant="2"][data-p-face="serif"] .inserted-copy { font-family: ui-serif, serif; }',
|
||||
'[data-impeccable-variant="2"][data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
|
||||
'[data-impeccable-variant="3"] .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
'[data-impeccable-variant="3"] .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
'[data-impeccable-variant="3"][data-p-italic] .inserted-copy { font-style: italic; }',
|
||||
].join('\n')
|
||||
: [
|
||||
'@scope ([data-impeccable-variant="1"]) {',
|
||||
' :scope .inserted-copy {',
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
' }',
|
||||
'}',
|
||||
@@ -276,7 +302,7 @@ function generateInsertFakeVariants(context = {}) {
|
||||
' :scope[data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
|
||||
'}',
|
||||
'@scope ([data-impeccable-variant="3"]) {',
|
||||
' :scope .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
' :scope .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
' :scope[data-p-italic] .inserted-copy { font-style: italic; }',
|
||||
'}',
|
||||
].join('\n');
|
||||
@@ -287,6 +313,187 @@ function generateInsertFakeVariants(context = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Svelte component preview (contract v2)
|
||||
//
|
||||
// The scaffolder hands the agent stubs that already carry the selection's
|
||||
// control flow ({#each}, {#if}) and prop references. A variant that re-derives
|
||||
// markup from the live DOM would bake one item's rendered text into the loop
|
||||
// template — exactly the failure the v2 contract exists to prevent. So the fake
|
||||
// agent behaves like a well-behaved real one: it keeps every stub's script,
|
||||
// comment, and markup byte-for-byte and rewrites only the <style> block.
|
||||
//
|
||||
// Each variant gets a distinct computed-style marker on the selection root
|
||||
// (font-weight 300 / 900 / 600) so the E2E suite can prove which variant is
|
||||
// actually mounted, plus the param hooks live.md section 7 describes:
|
||||
// `var(--p-<id>, default)` for range/toggle and `:global([data-p-<id>="…"])`
|
||||
// for steps. Params are declared in componentDir/params.json by the writer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The computed `font-weight` each fake variant renders with, on every preview
|
||||
* path (HTML/JSX scoped CSS, Astro global-prefixed, Svelte component). The E2E
|
||||
* suite reads these back through getComputedStyle so "variant N is visible" is
|
||||
* a render fact rather than a bar-label claim.
|
||||
*/
|
||||
export const FAKE_VARIANT_FONT_WEIGHTS = { 1: '300', 2: '900', 3: '600' };
|
||||
|
||||
async function generateSvelteComponentFakeVariants(event, context = {}) {
|
||||
const manifest = await readSvelteComponentManifest(context);
|
||||
if (!manifest || manifest.mode === 'insert') return null;
|
||||
|
||||
const shape = svelteSelectionShape(manifest.originalMarkup || '');
|
||||
const count = Math.max(1, Number(event.count) || 3);
|
||||
const variants = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const variantId = i + 1;
|
||||
variants.push({
|
||||
// Stub-preserving path: the writer ignores innerHtml entirely and keeps
|
||||
// the scaffolded markup. Kept as an empty string so the shared
|
||||
// normalizeVariantOutput pass has a string to walk.
|
||||
innerHtml: '',
|
||||
params: svelteFakeVariantParams(variantId),
|
||||
svelteComponent: { css: svelteFakeVariantCss(variantId, shape) },
|
||||
});
|
||||
}
|
||||
return { scopedCss: '', variants };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection root + first classed descendant, read off the scaffold's own
|
||||
* `originalMarkup`. Param-conditioned selectors need a descendant: after
|
||||
* accept, `[data-p-*]` is stripped from the selector, and a rule whose whole
|
||||
* selector was the stripped `:global([data-p-x="y"])` would be dropped along
|
||||
* with it. Selections without a classed descendant still declare their params;
|
||||
* they just don't wire CSS to them.
|
||||
*/
|
||||
function svelteSelectionShape(originalMarkup) {
|
||||
const openTags = [...String(originalMarkup || '').matchAll(/<([a-zA-Z][\w:-]*)\b([^>]*)>/g)];
|
||||
const described = openTags.map(([, tag, attrs]) => ({
|
||||
tag: tag.toLowerCase(),
|
||||
className: staticClassToken(attrs),
|
||||
}));
|
||||
const root = described[0] || { tag: 'div', className: '' };
|
||||
const descendant = described.slice(1).find((entry) => entry.className);
|
||||
return {
|
||||
rootSelector: root.className ? `.${root.className}` : root.tag,
|
||||
descendantSelector: descendant ? `.${descendant.className}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function staticClassToken(attrs) {
|
||||
const match = String(attrs || '').match(/\bclass\s*=\s*(["'])(.*?)\1/);
|
||||
if (!match) return '';
|
||||
return match[2].split(/\s+/).find((token) => token && !token.includes('{')) || '';
|
||||
}
|
||||
|
||||
function svelteFakeVariantParams(variantId) {
|
||||
if (variantId === 1) {
|
||||
return [
|
||||
{ id: 'lightness', kind: 'range', min: 0.3, max: 0.7, step: 0.05, default: 0.5, label: 'Lightness' },
|
||||
];
|
||||
}
|
||||
if (variantId === 2) {
|
||||
return [
|
||||
{ id: 'lead', kind: 'range', min: 1.2, max: 2, step: 0.1, default: 1.4, label: 'Lead' },
|
||||
{
|
||||
id: 'density',
|
||||
kind: 'steps',
|
||||
default: 'airy',
|
||||
label: 'Density',
|
||||
options: [
|
||||
{ value: 'airy', label: 'Airy' },
|
||||
{ value: 'snug', label: 'Snug' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{ id: 'italic', kind: 'toggle', default: false, label: 'Italic' }];
|
||||
}
|
||||
|
||||
function svelteFakeVariantCss(variantId, { rootSelector, descendantSelector }) {
|
||||
const lines = [];
|
||||
if (variantId === 1) {
|
||||
lines.push(`${rootSelector} { font-weight: 300; color: oklch(var(--p-lightness, 0.5) 0.25 25); }`);
|
||||
if (descendantSelector) lines.push(`${descendantSelector} { border-radius: 8px; }`);
|
||||
} else if (variantId === 2) {
|
||||
lines.push(`${rootSelector} { font-weight: 900; line-height: var(--p-lead, 1.4); }`);
|
||||
if (descendantSelector) {
|
||||
lines.push(`:global([data-p-density="airy"]) ${descendantSelector} { letter-spacing: 0.14em; }`);
|
||||
lines.push(`:global([data-p-density="snug"]) ${descendantSelector} { letter-spacing: 0.01em; }`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${rootSelector} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`);
|
||||
if (descendantSelector) {
|
||||
lines.push(`:global([data-p-italic]) ${descendantSelector} { font-style: italic; }`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function readSvelteComponentManifest(context = {}) {
|
||||
const { tmp, wrapInfo } = context;
|
||||
if (!tmp || !wrapInfo?.file) return null;
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a variant component's <style> block, keeping everything above it
|
||||
* (script, prop comment, markup) exactly as the scaffolder wrote it.
|
||||
*/
|
||||
export function restyleSvelteComponentSource(source, css) {
|
||||
const text = String(source || '');
|
||||
const styleStart = text.lastIndexOf('<style');
|
||||
const head = (styleStart === -1 ? text : text.slice(0, styleStart)).replace(/\s+$/, '');
|
||||
const body = String(css || '').trim() || ':global(*) {}';
|
||||
const indented = body.split('\n').map((line) => (line.trim() ? ' ' + line : '')).join('\n');
|
||||
return `${head}\n\n<style>\n${indented}\n</style>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-author every variant of a live component session and tell the server the
|
||||
* publish happened, exactly as the poll loop does after a generate. The server
|
||||
* snapshots a fresh `r<N>/` revision dir on the `done` reply, so the browser
|
||||
* imports from a path it has never seen and cannot serve a cached compile of
|
||||
* the previous content.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.tmp app root (where the manifest path resolves)
|
||||
* @param {string} opts.manifestFile manifest path relative to `tmp`
|
||||
* @param {{port: number, token: string}} opts.live
|
||||
* @param {(variantNum: number, shape: object) => string} opts.css
|
||||
*/
|
||||
export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) {
|
||||
const manifestPath = path.join(tmp, manifestFile);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
const shape = svelteSelectionShape(manifest.originalMarkup || '');
|
||||
const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
|
||||
for (let variantNum = 1; variantNum <= count; variantNum++) {
|
||||
const file = path.join(componentDir, `v${variantNum}.svelte`);
|
||||
let source;
|
||||
try { source = await fs.readFile(file, 'utf-8'); } catch { continue; }
|
||||
await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8');
|
||||
}
|
||||
const res = await fetch(`http://127.0.0.1:${live.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: live.token,
|
||||
type: 'done',
|
||||
sourceEventType: 'generate',
|
||||
id: manifest.id,
|
||||
file: manifestFile,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`);
|
||||
return { manifest, shape };
|
||||
}
|
||||
|
||||
export function insertTargetFromEvent(event) {
|
||||
const anchor = event?.insert?.anchor || {};
|
||||
const classes = Array.isArray(anchor.classes)
|
||||
@@ -352,6 +559,74 @@ function attrEscape(str, { svelte = false } = {}) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSX-source counterpart to the Svelte prop contract.
|
||||
*
|
||||
* When the picked element's source content is a bare JSX expression (or text
|
||||
* mixed with expressions), return it verbatim so the variant markup keeps the
|
||||
* binding instead of the rendered snapshot. Returns null for every other
|
||||
* shape, including nested elements, so this stays a narrow substitution rather
|
||||
* than a general source-copy path.
|
||||
*
|
||||
* @param {{ wrapInfo?: object, tmp?: string }} context
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function readJsxExpressionInner(context = {}) {
|
||||
const wrapInfo = context.wrapInfo;
|
||||
if (!wrapInfo) return null;
|
||||
// Svelte previews bind through propContract downstream; leave them alone.
|
||||
if (wrapInfo.previewMode === 'svelte-component') return null;
|
||||
if (wrapInfo.commentSyntax?.open !== '{/*') return null;
|
||||
|
||||
const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp);
|
||||
const inner = extractInnerSourceMarkup(original);
|
||||
if (inner == null) return null;
|
||||
const trimmed = inner.trim();
|
||||
if (!trimmed || trimmed.includes('<')) return null;
|
||||
if (!/\{[^{}]+\}/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the picked element's source markup from the scaffold. Deferred
|
||||
* writes carry it in `wrapperBlock`; otherwise the wrapper is already in the
|
||||
* file and the same block can be read back from disk.
|
||||
*/
|
||||
async function readOriginalMarkupFromWrap(wrapInfo, tmp) {
|
||||
let text = wrapInfo.wrapperBlock;
|
||||
if (!text && tmp && wrapInfo.file) {
|
||||
try {
|
||||
text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
|
||||
const lines = String(text).split('\n');
|
||||
const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"'));
|
||||
if (startIdx === -1) return null;
|
||||
const indent = (lines[startIdx].match(/^\s*/) || [''])[0];
|
||||
const closer = `${indent}</div>`;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Content between an element's opening and closing tag, or null. */
|
||||
function extractInnerSourceMarkup(markup) {
|
||||
const src = String(markup || '').trim();
|
||||
if (!src) return null;
|
||||
const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/);
|
||||
if (!open || open[2].trim().endsWith('/')) return null;
|
||||
const tag = open[1].toLowerCase();
|
||||
const closeIdx = src.toLowerCase().lastIndexOf(`</${tag}`);
|
||||
if (closeIdx < open[0].length) return null;
|
||||
if (!/^<\/[A-Za-z][\w:.-]*\s*>$/.test(src.slice(closeIdx))) return null;
|
||||
return src.slice(open[0].length, closeIdx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml
|
||||
* in HTML form; the orchestrator translates per the target file's syntax.
|
||||
@@ -1402,6 +1677,15 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
|
||||
for (let i = 0; i < output.variants.length; i++) {
|
||||
const variantId = i + 1;
|
||||
const variant = output.variants[i];
|
||||
// Contract-v2 path: keep the scaffolded stub (control flow + prop
|
||||
// references) and swap only its <style> block.
|
||||
if (variant.svelteComponent && !isInsert) {
|
||||
const variantPath = path.join(componentDir, `v${variantId}.svelte`);
|
||||
const stub = await fs.readFile(variantPath, 'utf-8');
|
||||
await fs.writeFile(variantPath, restyleSvelteComponentSource(stub, variant.svelteComponent.css), 'utf-8');
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
continue;
|
||||
}
|
||||
const tag = firstTagName(variant.innerHtml) || firstTagName(baseMarkup) || 'div';
|
||||
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
|
||||
if (!isInsert && contract.length > 0 && !propNames.some((name) => markup.includes(`{${name}}`))) {
|
||||
@@ -1573,6 +1857,13 @@ export async function runAgentLoop({
|
||||
steerTarget,
|
||||
}) {
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
// Everything the agent needs to republish a component session without
|
||||
// re-running generate: the scaffold info and the variant set it authored.
|
||||
const publishedComponentSessions = new Map();
|
||||
// A republish that fails to mount for the same reason would loop forever;
|
||||
// repair each (session, variant) at most once and let the user's Retry (or
|
||||
// the mount-error card) own anything beyond that.
|
||||
const repairedMounts = new Set();
|
||||
|
||||
while (!signal.aborted) {
|
||||
let event;
|
||||
@@ -1689,7 +1980,7 @@ export async function runAgentLoop({
|
||||
// the request for the remaining variants completes.
|
||||
trace('agent.generate.start', { id: event.id, count: event.count });
|
||||
let output = normalizeVariantOutput(
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo, tmp }),
|
||||
wrapInfo,
|
||||
);
|
||||
if (atomicDelayMs > 0) {
|
||||
@@ -1706,6 +1997,7 @@ export async function runAgentLoop({
|
||||
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
publishedComponentSessions.set(event.id, { wrapInfo, event, output });
|
||||
} else if (wrapInfo.sourceWritten === false) {
|
||||
await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
} else {
|
||||
@@ -1745,6 +2037,56 @@ export async function runAgentLoop({
|
||||
continue;
|
||||
}
|
||||
|
||||
// The browser could not render something the agent published. Only the
|
||||
// agent can fix that, which is why the server queues it as a first-class
|
||||
// event instead of leaving it in the journal. Rewriting the variant files
|
||||
// and replying `done` makes the server snapshot a fresh revision dir and
|
||||
// rebroadcast, which is what drives the browser's remount.
|
||||
if (event.type === 'variant_mount_failed') {
|
||||
const key = `${event.id}|${event.variant}`;
|
||||
const published = publishedComponentSessions.get(event.id);
|
||||
log(`variant_mount_failed id=${event.id} variant=${event.variant} error=${JSON.stringify(event.error)}`);
|
||||
if (agent.autoRepairMountFailures === false) {
|
||||
log('auto-repair disabled; leaving the mount-error card for the user to retry');
|
||||
continue;
|
||||
}
|
||||
if (!published) {
|
||||
log('no published component session to repair; ignoring');
|
||||
continue;
|
||||
}
|
||||
if (repairedMounts.has(key)) {
|
||||
log('already republished this variant once; not looping');
|
||||
continue;
|
||||
}
|
||||
repairedMounts.add(key);
|
||||
try {
|
||||
await writeSvelteComponentVariants({
|
||||
tmp,
|
||||
wrapInfo: published.wrapInfo,
|
||||
event: published.event,
|
||||
output: published.output,
|
||||
writeParams: true,
|
||||
});
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: 'done',
|
||||
sourceEventType: 'generate',
|
||||
id: event.id,
|
||||
file: published.wrapInfo.file,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
log(`republished component variants for ${event.id}`);
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
log('variant_mount_failed repair failed: ' + err.message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'manual_edit_apply') {
|
||||
const entryCount = event.batch?.entries?.length || 0;
|
||||
const opCount = (event.batch?.entries || []).reduce((sum, entry) => sum + (entry.ops?.length || 0), 0) || entryCount;
|
||||
|
||||
+127
-22
@@ -6,6 +6,8 @@
|
||||
* - npm install (the fixture's runtime.install command)
|
||||
* - live-server.mjs --background (returns {pid, port, token})
|
||||
* - live-inject.mjs --port (patches the framework HTML entry)
|
||||
* ...or, for a fixture declaring runtime.appDir, one live.mjs boot from
|
||||
* the repo root that resolves the app, starts the server, and injects
|
||||
* - the fixture's framework dev server (vite, vite dev, npx vite, ...)
|
||||
* - Playwright Chromium page
|
||||
* - the fake-agent poll loop (in this same node process)
|
||||
@@ -28,6 +30,27 @@ const FIXTURES_DIR = join(REPO_ROOT, 'tests', 'framework-fixtures');
|
||||
|
||||
export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App directory
|
||||
//
|
||||
// Most fixtures are their own app: the repo root is what the dev server
|
||||
// serves. A fixture that declares `runtime.appDir` puts the served app one or
|
||||
// more levels below the repo root (the shape live mode's root resolution has
|
||||
// to auto-detect). For those, install, the live config, the dev server, and
|
||||
// every fixture-relative source path belong to the app dir; git stays at the
|
||||
// repo root.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function appDirFor(fixture) {
|
||||
const dir = fixture?.runtime?.appDir;
|
||||
return typeof dir === 'string' && dir !== '' && dir !== '.' ? dir : null;
|
||||
}
|
||||
|
||||
export function appRootFor(tmp, fixture) {
|
||||
const dir = appDirFor(fixture);
|
||||
return dir ? join(tmp, dir) : tmp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,8 +61,9 @@ export function stageFixture(name, fixture, { fixtureRoot = join(FIXTURES_DIR, n
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
|
||||
cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true });
|
||||
writeFileSync(join(tmp, '.gitignore'), gitignore);
|
||||
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
|
||||
const appRoot = appRootFor(tmp, fixture);
|
||||
mkdirSync(join(appRoot, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(appRoot, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
|
||||
|
||||
execFileSync('git', ['init', '-q'], { cwd: tmp });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
|
||||
@@ -107,6 +131,39 @@ export function startLiveServer(tmp) {
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full live boot through `live.mjs`, the entry point a real agent runs.
|
||||
*
|
||||
* Used by fixtures whose app is not at the repo root: `cwd` is the repo root,
|
||||
* and live.mjs is the step that resolves the roots, persists the manifest and
|
||||
* pointer, starts the server under the app, and injects the script tag there.
|
||||
* Returns the parsed live.mjs payload plus the {pid, port, token} the rest of
|
||||
* the session needs.
|
||||
*/
|
||||
export function runLiveBoot(cwd, appRoot) {
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[join(SCRIPTS_DIR, 'live.mjs')],
|
||||
{ cwd, encoding: 'utf-8' },
|
||||
);
|
||||
let boot;
|
||||
try {
|
||||
boot = JSON.parse(out.trim());
|
||||
} catch {
|
||||
throw new Error('live.mjs returned unparseable output:\n' + out);
|
||||
}
|
||||
if (!boot.ok) throw new Error('live.mjs boot failed: ' + JSON.stringify(boot));
|
||||
|
||||
let pid = null;
|
||||
try {
|
||||
pid = JSON.parse(readFileSync(join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8')).pid;
|
||||
} catch { /* reported below */ }
|
||||
if (!pid || !boot.serverPort) {
|
||||
throw new Error('live.mjs boot produced no reachable server: ' + JSON.stringify(boot));
|
||||
}
|
||||
return { boot, live: { pid, port: boot.serverPort, token: boot.serverToken } };
|
||||
}
|
||||
|
||||
export function stopLiveServer(tmp) {
|
||||
try {
|
||||
execFileSync(
|
||||
@@ -228,6 +285,11 @@ export async function stopDevServer(child) {
|
||||
* omit `agent` so the deterministic in-process loop is not started.
|
||||
* @param {(context: object) => Promise<void>|void} [opts.prepareTmp]
|
||||
* @param {(msg: string) => void} [opts.log]
|
||||
*
|
||||
* The returned session carries `tmp` (staged repo root, where git lives) and
|
||||
* `appRoot` (what the dev server serves). They are the same path unless the
|
||||
* fixture declares `runtime.appDir`; resolve fixture-relative source paths
|
||||
* against `appRoot`.
|
||||
*/
|
||||
export async function bootFixtureSession({
|
||||
name,
|
||||
@@ -247,7 +309,10 @@ export async function bootFixtureSession({
|
||||
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
|
||||
|
||||
const tmp = stageFixture(name, fixture, { fixtureRoot });
|
||||
const appDir = appDirFor(fixture);
|
||||
const appRoot = appRootFor(tmp, fixture);
|
||||
let live;
|
||||
let liveBoot = null;
|
||||
let dev;
|
||||
let agentAbort;
|
||||
let agentDone;
|
||||
@@ -261,7 +326,7 @@ export async function bootFixtureSession({
|
||||
try { if (externalWorker?.stop) await externalWorker.stop(); } catch {}
|
||||
try { if (externalWorker?.done) await externalWorker.done.catch(() => {}); } catch {}
|
||||
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
|
||||
try { if (live) stopLiveServer(tmp); } catch {}
|
||||
try { if (live) stopLiveServer(appRoot); } catch {}
|
||||
if (!keepTmp) {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
} else {
|
||||
@@ -271,44 +336,61 @@ export async function bootFixtureSession({
|
||||
|
||||
const stopLiveForDeferredWork = () => {
|
||||
if (!live) return;
|
||||
stopLiveServer(tmp);
|
||||
stopLiveServer(appRoot);
|
||||
live = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
if (prepareTmp) await prepareTmp({ tmp, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
if (prepareTmp) await prepareTmp({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
trace('setup.install.start', { fixture: name });
|
||||
log(`installing deps`);
|
||||
runInstall(tmp, runtime.install);
|
||||
runInstall(appRoot, runtime.install);
|
||||
trace('setup.install.end', { fixture: name });
|
||||
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
|
||||
|
||||
const liveStartedAt = Date.now();
|
||||
trace('setup.live_server.start', { fixture: name });
|
||||
log(`starting live-server`);
|
||||
live = startLiveServer(tmp);
|
||||
trace('setup.live_server.end', { fixture: name, port: live.port });
|
||||
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
if (appDir) {
|
||||
// The whole point of an appDir fixture: boot from the repo root and let
|
||||
// live.mjs find the app, so the run proves root resolution rather than
|
||||
// assuming it. live.mjs starts the server and injects in one step.
|
||||
log(`booting live.mjs from the repo root (app is ${appDir}/)`);
|
||||
const booted = runLiveBoot(tmp, appRoot);
|
||||
liveBoot = booted.boot;
|
||||
live = booted.live;
|
||||
trace('setup.live_server.end', { fixture: name, port: live.port, appRoot: liveBoot.roots?.appRoot });
|
||||
log(`live.mjs booted on ${live.port} (appRoot=${liveBoot.roots?.appRoot}) in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
} else {
|
||||
log(`starting live-server`);
|
||||
live = startLiveServer(tmp);
|
||||
trace('setup.live_server.end', { fixture: name, port: live.port });
|
||||
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
||||
}
|
||||
|
||||
if (startWorker) {
|
||||
trace('setup.worker.start', { fixture: name });
|
||||
externalWorker = await startWorker({ tmp, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
externalWorker = await startWorker({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
trace('setup.worker.end', { fixture: name });
|
||||
}
|
||||
|
||||
const injectStartedAt = Date.now();
|
||||
trace('setup.inject.start', { fixture: name });
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port, live.token);
|
||||
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)}`);
|
||||
if (!appDir) {
|
||||
const injectStartedAt = Date.now();
|
||||
trace('setup.inject.start', { fixture: name });
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port, live.token);
|
||||
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)}`);
|
||||
} else {
|
||||
trace('setup.inject.end', { fixture: name, files: liveBoot.pageFiles || [] });
|
||||
log(`live.mjs injected into ${(liveBoot.pageFiles || []).join(', ') || '(nothing)'}`);
|
||||
}
|
||||
|
||||
const devStartedAt = Date.now();
|
||||
trace('setup.dev_server.start', { fixture: name });
|
||||
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
|
||||
dev = startDevServer(tmp, runtime);
|
||||
dev = startDevServer(appRoot, 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)}`);
|
||||
@@ -317,7 +399,7 @@ export async function bootFixtureSession({
|
||||
if (agent) {
|
||||
agentAbort = new AbortController();
|
||||
const loopOptions = {
|
||||
tmp,
|
||||
tmp: appRoot,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
@@ -338,15 +420,34 @@ export async function bootFixtureSession({
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
const consoleErrors = [];
|
||||
// Failed network requests, kept separately from console text so the
|
||||
// assertions can key on the request URL rather than on Chromium's
|
||||
// URL-less "Failed to load resource" console string.
|
||||
const failedRequests = [];
|
||||
page.on('pageerror', (err) => {
|
||||
consoleErrors.push(`pageerror: ${err.message}\n${err.stack || ''}`);
|
||||
});
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(`console.error: ${msg.text()}`);
|
||||
else if (process.env.IMPECCABLE_E2E_CONSOLE && /\[impeccable\]|\[vite\]/.test(msg.text())) {
|
||||
if (msg.type() === 'error') {
|
||||
// Chromium reports resource failures with the URL only in the message
|
||||
// location, not in the text. Append it so the console-hygiene filter
|
||||
// can tell a favicon 404 from a live-preview 404.
|
||||
let url = '';
|
||||
try { url = msg.location()?.url || ''; } catch { /* older playwright */ }
|
||||
consoleErrors.push(`console.error: ${msg.text()}${url ? ` [${url}]` : ''}`);
|
||||
} else if (process.env.IMPECCABLE_E2E_CONSOLE && /\[impeccable\]|\[vite\]/.test(msg.text())) {
|
||||
log(`[console.${msg.type()}] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (req) => {
|
||||
let reason = 'request failed';
|
||||
try { reason = req.failure()?.errorText || reason; } catch { /* ignore */ }
|
||||
failedRequests.push({ url: req.url(), status: 0, reason });
|
||||
});
|
||||
page.on('response', (res) => {
|
||||
const status = res.status();
|
||||
if (status >= 400) failedRequests.push({ url: res.url(), status, reason: `HTTP ${status}` });
|
||||
});
|
||||
if (process.env.IMPECCABLE_E2E_CONSOLE) {
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) log(`[nav] main frame → ${frame.url()}`);
|
||||
@@ -364,12 +465,16 @@ export async function bootFixtureSession({
|
||||
|
||||
return {
|
||||
tmp,
|
||||
appRoot,
|
||||
appDir,
|
||||
page,
|
||||
ctx,
|
||||
dev,
|
||||
live,
|
||||
liveBoot,
|
||||
worker: externalWorker,
|
||||
consoleErrors,
|
||||
failedRequests,
|
||||
stopLiveServer: stopLiveForDeferredWork,
|
||||
teardown,
|
||||
};
|
||||
|
||||
@@ -674,6 +674,62 @@ export async function clickPrev(page) {
|
||||
await clickBarButton(page, '←');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until one variant step has fully landed: the bar counter, the bar's own
|
||||
* notion of the visible variant, and (on component previews) the variant that
|
||||
* is actually mounted all agree.
|
||||
*
|
||||
* Reading the counter alone is not enough. The counter can still show the
|
||||
* previous step when the next click goes out, and two clicks that arrive
|
||||
* inside one settle window leave the session on a variant nobody asked for.
|
||||
*/
|
||||
export async function waitForVariantSettled(page, expected, count, { timeout = 15_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ expected, count, barSel }) => {
|
||||
const bar = window.__impeccableLiveQuery(barSel);
|
||||
if (!bar || !(bar.textContent || '').includes(`${expected}/${count}`)) return false;
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
if (!debugState) return true;
|
||||
if (debugState.visibleVariant !== expected) return false;
|
||||
if (debugState.hasSvelteComponentSession && debugState.mountedSvelteVariant !== expected) return false;
|
||||
return true;
|
||||
},
|
||||
{ expected, count, barSel: BAR_ID },
|
||||
{ timeout },
|
||||
);
|
||||
} catch (err) {
|
||||
const debugState = await page
|
||||
.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null)
|
||||
.catch(() => null);
|
||||
throw new Error(
|
||||
`variant ${expected}/${count} never settled; debugState=${JSON.stringify(debugState)} (${err.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the comparison to `targetVariant`, one settled click at a time.
|
||||
* Returns the variant now visible.
|
||||
*/
|
||||
export async function cycleToVariant(page, targetVariant, count, { settleTimeout = 15_000 } = {}) {
|
||||
let visible = await getVisibleVariant(page);
|
||||
let steps = 0;
|
||||
while (visible !== targetVariant) {
|
||||
if (steps++ > count + 6) {
|
||||
throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`);
|
||||
}
|
||||
const forward = visible == null || visible < targetVariant;
|
||||
const next = visible == null ? 1 : (forward ? visible + 1 : visible - 1);
|
||||
if (forward) await clickNext(page);
|
||||
else await clickPrev(page);
|
||||
await waitForVariantSettled(page, next, count, { timeout: settleTimeout });
|
||||
visible = await getVisibleVariant(page);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function barButtonMatch(label) {
|
||||
if (label instanceof RegExp) return { kind: 'regex', source: label.source, flags: label.flags };
|
||||
if (label && typeof label === 'object' && label.ariaLabel) return { kind: 'aria', value: label.ariaLabel };
|
||||
@@ -769,6 +825,192 @@ export async function getVisibleVariant(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tune popover
|
||||
//
|
||||
// buildParamsPanel renders one row per param with no stable ids: a label row
|
||||
// (label span + readout span) followed by the control (range input, toggle
|
||||
// track button, or a segmented row of buttons). The label text is the only
|
||||
// handle a user has too, so that is what these helpers match on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TUNE_BUTTON = '[data-iceq-tune="1"]';
|
||||
const PARAMS_PANEL_ID = '#impeccable-live-params-panel';
|
||||
|
||||
/** Open the Tune popover and wait for its rows to render. */
|
||||
export async function openTunePanel(page, { timeout = 5_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const tune = window.__impeccableLiveQuery(sel);
|
||||
return Boolean(tune) && tune.disabled === false;
|
||||
},
|
||||
TUNE_BUTTON,
|
||||
{ timeout },
|
||||
);
|
||||
await clickLiveControl(page, TUNE_BUTTON);
|
||||
await page.waitForFunction(
|
||||
(sel) => (window.__impeccableLiveQuery(sel)?.querySelectorAll(':scope > div > div').length || 0) > 0,
|
||||
PARAMS_PANEL_ID,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a `range` knob to `value`. Setting `.value` + dispatching `input` is
|
||||
* what a real drag produces; the panel's listener reads the input, not the
|
||||
* event, so this exercises the same code path.
|
||||
*/
|
||||
export async function setTuneRange(page, label, value) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const applied = await page.evaluate(({ panelSel, label, value }) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const row = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.find((candidate) => candidate.querySelector('span')?.textContent?.trim() === label);
|
||||
const input = row?.querySelector('input[type="range"]');
|
||||
if (!input) return null;
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return parseFloat(input.value);
|
||||
}, { panelSel: PARAMS_PANEL_ID, label, value });
|
||||
if (applied == null) {
|
||||
throw new Error(`Tune range "${label}" not found. ${await describeTunePanel(page)}`);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Panel contents + variant state, for failures that would otherwise be mute. */
|
||||
async function describeTunePanel(page) {
|
||||
const snapshot = await page.evaluate((panelSel) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const rows = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.map((row) => (row.querySelector('span')?.textContent || '').trim());
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null;
|
||||
return {
|
||||
rows,
|
||||
barText: debugState?.barText || null,
|
||||
visibleVariant: debugState?.visibleVariant ?? null,
|
||||
mountedSvelteVariant: debugState?.mountedSvelteVariant ?? null,
|
||||
state: debugState?.state || null,
|
||||
};
|
||||
}, PARAMS_PANEL_ID).catch((err) => ({ error: err.message }));
|
||||
return `Panel snapshot: ${JSON.stringify(snapshot)}`;
|
||||
}
|
||||
|
||||
/** Click one option of a `steps` param by its visible option label. */
|
||||
export async function chooseTuneStep(page, label, optionLabel) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const clicked = await page.evaluate(({ panelSel, label, optionLabel }) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const row = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.find((candidate) => candidate.querySelector('span')?.textContent?.trim() === label);
|
||||
if (!row) return 'no-row';
|
||||
const button = [...row.querySelectorAll('button')]
|
||||
.find((btn) => (btn.textContent || '').trim() === optionLabel);
|
||||
if (!button) return 'no-option';
|
||||
button.click();
|
||||
return 'ok';
|
||||
}, { panelSel: PARAMS_PANEL_ID, label, optionLabel });
|
||||
if (clicked !== 'ok') {
|
||||
throw new Error(`Tune steps "${label}" option "${optionLabel}" not found (${clicked})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount-error card (component previews)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOUNT_ERROR_ID = '#impeccable-live-mount-error';
|
||||
const MOUNT_RETRY = '[data-impeccable-mount-retry="true"]';
|
||||
|
||||
/** Wait for the persistent mount-error card and return its text. */
|
||||
export async function waitForMountErrorCard(page, { variant, timeout = 20_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
({ sel, variant }) => {
|
||||
const card = window.__impeccableLiveQuery(sel);
|
||||
if (!card) return false;
|
||||
if (variant == null) return true;
|
||||
return (card.textContent || '').includes(`Variant ${variant} failed to load`);
|
||||
},
|
||||
{ sel: MOUNT_ERROR_ID, variant: variant ?? null },
|
||||
{ timeout },
|
||||
);
|
||||
return page.evaluate((sel) => window.__impeccableLiveQuery(sel)?.textContent || '', MOUNT_ERROR_ID);
|
||||
}
|
||||
|
||||
export async function isMountErrorCardVisible(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
return page.evaluate((sel) => Boolean(window.__impeccableLiveQuery(sel)), MOUNT_ERROR_ID);
|
||||
}
|
||||
|
||||
/** Click the card's Retry button (re-imports the manifest's current revision). */
|
||||
export async function clickMountRetry(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const clicked = await page.evaluate(({ cardSel, retrySel }) => {
|
||||
const button = window.__impeccableLiveQuery(cardSel)?.querySelector(retrySel);
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}, { cardSel: MOUNT_ERROR_ID, retrySel: MOUNT_RETRY });
|
||||
if (!clicked) throw new Error('mount-error Retry button not found');
|
||||
}
|
||||
|
||||
export async function waitForMountErrorCardGone(page, { timeout = 20_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
(sel) => !window.__impeccableLiveQuery(sel),
|
||||
MOUNT_ERROR_ID,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the picked element renders with `expected` computed font-weight.
|
||||
* The fake agent gives every variant a distinct weight, so this is the render
|
||||
* proof that variant N actually mounted (see FAKE_VARIANT_FONT_WEIGHTS).
|
||||
*/
|
||||
export async function waitForComputedFontWeight(page, selector, expected, { timeout = 10_000 } = {}) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ sel, expected }) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return Boolean(el) && getComputedStyle(el).fontWeight === expected;
|
||||
},
|
||||
{ sel: selector, expected: String(expected) },
|
||||
{ timeout },
|
||||
);
|
||||
} catch (err) {
|
||||
const snapshot = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null;
|
||||
const mount = document.querySelector('[data-impeccable-component-mount]');
|
||||
return {
|
||||
found: Boolean(el),
|
||||
fontWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
outerHTML: el?.outerHTML?.slice(0, 400) || null,
|
||||
mountHTML: mount?.outerHTML?.slice(0, 400) || null,
|
||||
barText: debugState?.barText || null,
|
||||
state: debugState?.state || null,
|
||||
visibleVariant: debugState?.visibleVariant ?? null,
|
||||
mountedSvelteVariant: debugState?.mountedSvelteVariant ?? null,
|
||||
};
|
||||
}, selector).catch((snapErr) => ({ error: snapErr.message }));
|
||||
throw new Error(
|
||||
`expected computed font-weight ${expected} on ${selector}; snapshot: ${JSON.stringify(snapshot)} (${err.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readComputedFontWeight(page, selector) {
|
||||
return page.evaluate((sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el ? getComputedStyle(el).fontWeight : null;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Accept — sends accept event with current variantId + paramValues.
|
||||
* The bar transitions to a "Saving..." spinner, then a green confirmed row.
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateEvent } from '../skill/scripts/live/event-validation.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { AGENT_PHASES, CLIENT_EVENT_TYPES, validateEvent } from '../skill/scripts/live/event-validation.mjs';
|
||||
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
|
||||
|
||||
const VALID_ID = 'a1b2c3d4';
|
||||
|
||||
@@ -98,15 +102,123 @@ describe('validateEvent — replace generate (regression)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — worker progress', () => {
|
||||
it('accepts bounded agent phases and rejects malformed telemetry', () => {
|
||||
describe('validateEvent — mount acknowledgements', () => {
|
||||
it('accepts a well-formed variant_mounted ack with and without a url', () => {
|
||||
assert.equal(validateEvent({ type: 'variant_mounted', id: VALID_ID, variant: 2 }), null);
|
||||
assert.equal(validateEvent({
|
||||
type: 'agent_phase',
|
||||
type: 'variant_mounted',
|
||||
id: VALID_ID,
|
||||
phase: 'first_variant_generating',
|
||||
durationMs: 123,
|
||||
variant: 1,
|
||||
url: 'http://localhost:5173/.impeccable/live/preview/v1.svelte',
|
||||
}), null);
|
||||
});
|
||||
|
||||
it('rejects malformed variant_mounted acks', () => {
|
||||
assert.match(validateEvent({ type: 'variant_mounted', id: 'nope', variant: 1 }), /malformed id/);
|
||||
assert.match(validateEvent({ type: 'variant_mounted', id: VALID_ID, variant: 0 }), /variant/);
|
||||
assert.match(validateEvent({ type: 'variant_mounted', id: VALID_ID, variant: 1.5 }), /variant/);
|
||||
assert.match(validateEvent({ type: 'variant_mounted', id: VALID_ID }), /variant/);
|
||||
assert.match(validateEvent({ type: 'variant_mounted', id: VALID_ID, variant: 1, url: 42 }), /url must be string/);
|
||||
assert.match(
|
||||
validateEvent({ type: 'variant_mounted', id: VALID_ID, variant: 1, url: 'u'.repeat(2001) }),
|
||||
/url too long/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a well-formed variant_mount_failed report', () => {
|
||||
assert.equal(validateEvent({
|
||||
type: 'variant_mount_failed',
|
||||
id: VALID_ID,
|
||||
variant: 3,
|
||||
url: '/preview/v3.svelte',
|
||||
error: 'Failed to fetch dynamically imported module',
|
||||
}), null);
|
||||
});
|
||||
|
||||
it('requires url and error on variant_mount_failed and caps their length', () => {
|
||||
const base = { type: 'variant_mount_failed', id: VALID_ID, variant: 1, url: '/v1.svelte', error: 'boom' };
|
||||
assert.match(validateEvent({ ...base, url: undefined }), /url required/);
|
||||
assert.match(validateEvent({ ...base, url: ' ' }), /url required/);
|
||||
assert.match(validateEvent({ ...base, error: undefined }), /error required/);
|
||||
assert.match(validateEvent({ ...base, error: ' ' }), /error required/);
|
||||
assert.match(validateEvent({ ...base, url: 'u'.repeat(2001) }), /url too long/);
|
||||
assert.match(validateEvent({ ...base, error: 'e'.repeat(1001) }), /error too long/);
|
||||
assert.match(validateEvent({ ...base, id: 'ZZZZ' }), /malformed id/);
|
||||
assert.match(validateEvent({ ...base, variant: '2' }), /variant/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — worker progress', () => {
|
||||
it('accepts every phase the server emits and rejects malformed telemetry', () => {
|
||||
for (const phase of AGENT_PHASES) {
|
||||
assert.equal(
|
||||
validateEvent({ type: 'agent_phase', id: VALID_ID, phase, durationMs: 123 }),
|
||||
null,
|
||||
'event=live_event_validation.agent_phase actor=server operation=validate risk=server_phase_rejected phase=' + phase,
|
||||
);
|
||||
}
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'Not valid' }), /phase/);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'valid', durationMs: -1 }), /durationMs/);
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: 'all_variants_ready', durationMs: -1 }), /durationMs/);
|
||||
});
|
||||
|
||||
it('rejects a phase no component models instead of ranking it as unknown', () => {
|
||||
// These were carried in the browser's PHASE_RANK table and its status
|
||||
// strings long after the server stopped emitting them. A phase nothing
|
||||
// sends is a phase nothing can render.
|
||||
for (const retired of [
|
||||
'first_variant_generating',
|
||||
'first_variant_validating',
|
||||
'remaining_variants_generating',
|
||||
'remaining_variants_validating',
|
||||
'variant_parameters_generating',
|
||||
'variant_parameters_validating',
|
||||
'parameters_ready',
|
||||
]) {
|
||||
assert.match(
|
||||
validateEvent({ type: 'agent_phase', id: VALID_ID, phase: retired }),
|
||||
/unknown phase/,
|
||||
'event=live_event_validation.retired_phase actor=server operation=validate risk=dead_enum_value_revived phase=' + retired,
|
||||
);
|
||||
}
|
||||
assert.match(validateEvent({ type: 'agent_phase', id: VALID_ID, phase: '' }), /missing phase/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protocol vocabulary', () => {
|
||||
const SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../skill/scripts/live/event-validation.mjs', import.meta.url)),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
it('validates exactly the event types the vocabulary advertises', () => {
|
||||
for (const type of CLIENT_EVENT_TYPES) {
|
||||
assert.equal(
|
||||
/^Unknown event type/.test(String(validateEvent({ type }))),
|
||||
false,
|
||||
'event=live_vocabulary.client_event_types actor=browser operation=validate risk=advertised_type_unroutable type=' + type,
|
||||
);
|
||||
}
|
||||
assert.match(validateEvent({ type: 'not_a_real_event' }), /^Unknown event type/);
|
||||
});
|
||||
|
||||
it('keeps the enums in the vocabulary module rather than inlined here', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/\[\^a-z\]\{1,63\}|\{1,63\}/,
|
||||
'agent_phase must be checked against the enum, not a shape pattern',
|
||||
);
|
||||
assert.match(SOURCE, /from '\.\/vocabulary\.mjs'/);
|
||||
});
|
||||
|
||||
it('accepts every palette action as a generate action', () => {
|
||||
for (const action of VISUAL_ACTIONS) {
|
||||
assert.equal(validateEvent({
|
||||
type: 'generate',
|
||||
id: VALID_ID,
|
||||
count: 1,
|
||||
action,
|
||||
element: { outerHTML: '<button>Go</button>' },
|
||||
}), null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Conformance tests for the live-mode framework registry.
|
||||
*
|
||||
* Two jobs:
|
||||
*
|
||||
* 1. Every entry in FRAMEWORKS satisfies the contract the live scripts rely
|
||||
* on — a callable detect, a resolvable inject strategy, source traits
|
||||
* drawn from the closed sets, and undo functions for every patch kind it
|
||||
* can journal. A new framework module that forgets a field fails here
|
||||
* rather than at a user's inject.
|
||||
* 2. detect() lands on the expected entry for the static framework fixtures.
|
||||
* This is the seed of the conformance battery; behavioral conformance
|
||||
* (does the injected bundle actually boot in that framework) stays in the
|
||||
* live-e2e suite. Fixtures are read-only here.
|
||||
*
|
||||
* Plus the crash-safe injection journal, which is registry-layer code: it
|
||||
* heals through the entries' own undo functions.
|
||||
*
|
||||
* Run with: node --test tests/live-frameworks.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
COMMENT_SYNTAXES,
|
||||
FRAMEWORKS,
|
||||
INJECT_KINDS,
|
||||
PATCH_UNDOERS,
|
||||
PREVIEW_MODES,
|
||||
SOURCE_TRAIT_DEFAULTS,
|
||||
STYLE_MODES,
|
||||
TAG_PATCH_KIND,
|
||||
describeInjectArtifacts,
|
||||
frameworkIgnorePatterns,
|
||||
resolveFramework,
|
||||
resolveSourceTraits,
|
||||
} from '../skill/scripts/live/frameworks/index.mjs';
|
||||
import {
|
||||
clearInjectJournal,
|
||||
healInjectJournal,
|
||||
injectJournalPath,
|
||||
readInjectJournal,
|
||||
recordInjection,
|
||||
} from '../skill/scripts/live/frameworks/journal.mjs';
|
||||
import { insertTag } from '../skill/scripts/live/frameworks/tag-strategy.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
|
||||
|
||||
/** Read-only: the fixture's project tree plus its live config. */
|
||||
function fixtureProject(name) {
|
||||
const root = join(FIXTURES_DIR, name, 'files');
|
||||
const manifest = join(FIXTURES_DIR, name, 'fixture.json');
|
||||
if (!existsSync(root) || !existsSync(manifest)) return null;
|
||||
return { root, config: JSON.parse(readFileSync(manifest, 'utf-8')).config };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('framework registry — entry contract', () => {
|
||||
it('declares the documented detection priority, terminating in static-html', () => {
|
||||
assert.deepEqual(
|
||||
FRAMEWORKS.map((f) => f.name),
|
||||
['sveltekit', 'nuxt', 'tanstack-start', 'astro', 'nextjs', 'vite-generic', 'static-html'],
|
||||
'detection order is injection priority; live-inject.mjs used to hard-code exactly this',
|
||||
);
|
||||
assert.equal(FRAMEWORKS.at(-1).name, 'static-html');
|
||||
assert.ok(FRAMEWORKS.at(-1).detect(process.cwd(), null), 'the terminal entry must always match');
|
||||
});
|
||||
|
||||
it('gives every entry a unique name', () => {
|
||||
const names = FRAMEWORKS.map((f) => f.name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
for (const name of names) {
|
||||
assert.equal(typeof name, 'string');
|
||||
assert.ok(name.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
for (const framework of FRAMEWORKS) {
|
||||
describe(`entry · ${framework.name}`, () => {
|
||||
it('exposes a callable detect', () => {
|
||||
assert.equal(typeof framework.detect, 'function');
|
||||
});
|
||||
|
||||
it('resolves its inject strategy', () => {
|
||||
const { inject } = framework;
|
||||
assert.ok(inject, 'entry declares an inject strategy');
|
||||
assert.ok(INJECT_KINDS.includes(inject.kind), `unknown inject kind ${inject.kind}`);
|
||||
if (inject.kind === 'adapter') {
|
||||
assert.equal(typeof inject.apply, 'function');
|
||||
assert.equal(typeof inject.remove, 'function');
|
||||
assert.equal(typeof inject.artifacts, 'function', 'adapters must declare what they write');
|
||||
assert.equal(typeof inject.ignorePatterns, 'function');
|
||||
} else {
|
||||
// The tag strategy is shared; an entry declaring per-framework
|
||||
// apply/remove alongside kind:'tag' would silently never run.
|
||||
assert.equal(inject.apply, undefined);
|
||||
assert.equal(inject.remove, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers an undo for every patch kind it can journal', () => {
|
||||
for (const key of Object.keys(framework.inject.unpatch || {})) {
|
||||
assert.equal(typeof PATCH_UNDOERS[key], 'function', `no undo registered for ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares source traits from the closed sets', () => {
|
||||
const { source } = framework;
|
||||
assert.ok(Array.isArray(source.extensions) && source.extensions.length > 0);
|
||||
for (const ext of source.extensions) {
|
||||
assert.match(ext, /^\.[a-z0-9]+$/, 'extensions are lowercase and dotted');
|
||||
const traits = resolveSourceTraits(`Example${ext}`);
|
||||
assert.ok(PREVIEW_MODES.includes(traits.preview), `bad preview ${traits.preview}`);
|
||||
assert.ok(STYLE_MODES.includes(traits.styleMode), `bad styleMode ${traits.styleMode}`);
|
||||
assert.ok(COMMENT_SYNTAXES.includes(traits.commentSyntax), `bad commentSyntax ${traits.commentSyntax}`);
|
||||
assert.equal(typeof traits.styleTag, 'string');
|
||||
assert.equal(typeof traits.injectScriptAttrs, 'string');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps shared extensions in agreement across entries', () => {
|
||||
// `.tsx` belongs to tanstack-start, nextjs and vite-generic. resolveSourceTraits
|
||||
// returns the first claimant, so a disagreement would resolve by array
|
||||
// position instead of by intent.
|
||||
const seen = new Map();
|
||||
for (const framework of FRAMEWORKS) {
|
||||
for (const ext of framework.source.extensions) {
|
||||
const traits = { ...SOURCE_TRAIT_DEFAULTS, ...framework.source };
|
||||
delete traits.extensions;
|
||||
const prior = seen.get(ext);
|
||||
if (!prior) { seen.set(ext, { name: framework.name, traits }); continue; }
|
||||
assert.deepEqual(
|
||||
traits,
|
||||
prior.traits,
|
||||
`${framework.name} and ${prior.name} both claim ${ext} but disagree on its traits`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the defaults for an unclaimed extension', () => {
|
||||
const traits = resolveSourceTraits('src/helpers.ts');
|
||||
assert.equal(traits.framework, null);
|
||||
assert.equal(traits.preview, 'source');
|
||||
assert.equal(traits.styleMode, 'scoped');
|
||||
assert.equal(traits.commentSyntax, 'html');
|
||||
assert.equal(traits.injectScriptAttrs, '');
|
||||
});
|
||||
|
||||
it('routes the three authoring modes live-wrap depends on', () => {
|
||||
assert.equal(resolveSourceTraits('src/routes/+page.svelte').preview, 'component');
|
||||
assert.equal(resolveSourceTraits('src/pages/index.astro').styleMode, 'astro-global-prefixed');
|
||||
assert.equal(resolveSourceTraits('src/pages/index.astro').injectScriptAttrs, 'is:inline ');
|
||||
assert.equal(resolveSourceTraits('app/page.tsx').commentSyntax, 'jsx');
|
||||
assert.equal(resolveSourceTraits('index.html').commentSyntax, 'html');
|
||||
assert.equal(resolveSourceTraits('app/app.vue').styleMode, 'scoped');
|
||||
});
|
||||
|
||||
it('registers the generic tag undo', () => {
|
||||
assert.equal(typeof PATCH_UNDOERS[TAG_PATCH_KIND], 'function');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection against the static fixtures (read-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('framework registry — fixture detection', () => {
|
||||
/**
|
||||
* `sveltekit` is deliberately absent: that fixture is a bare src/app.html +
|
||||
* one route with no svelte.config and no package.json, and the SvelteKit
|
||||
* detector has always required one of those. It resolves to static-html and
|
||||
* takes the generic tag path, which is exactly what live-inject did before
|
||||
* the registry. `vite8-sveltekit` is the fully-shaped SvelteKit fixture.
|
||||
*/
|
||||
const EXPECTED = {
|
||||
'vite8-sveltekit': 'sveltekit',
|
||||
'vite8-sveltekit-stateful': 'sveltekit',
|
||||
'sveltekit-csp': 'sveltekit',
|
||||
'nuxt-vite7': 'nuxt',
|
||||
'nuxt-csp': 'nuxt',
|
||||
'tanstack-start': 'tanstack-start',
|
||||
// A plain TanStack Router SPA has no @tanstack/react-start and a static
|
||||
// index.html, so it must NOT take the SSR adapter.
|
||||
'tanstack-router-vite': 'vite-generic',
|
||||
astro: 'astro',
|
||||
'astro-vite7': 'astro',
|
||||
'nextjs-app': 'nextjs',
|
||||
'nextjs-app-router': 'nextjs',
|
||||
'vite8-react-plain': 'vite-generic',
|
||||
'vite8-react-ts': 'vite-generic',
|
||||
'multipage-with-generator': 'static-html',
|
||||
};
|
||||
|
||||
for (const [name, expected] of Object.entries(EXPECTED)) {
|
||||
it(`${name} resolves to ${expected}`, (t) => {
|
||||
const project = fixtureProject(name);
|
||||
if (!project) {
|
||||
t.skip(`fixture ${name} is not present`);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveFramework(project.root, project.config);
|
||||
assert.ok(resolved, 'a framework always resolves');
|
||||
assert.equal(resolved.framework.name, expected);
|
||||
assert.ok(resolved.project, 'detect returns a truthy project descriptor');
|
||||
});
|
||||
}
|
||||
|
||||
it('the bare sveltekit fixture keeps the pre-registry generic path', () => {
|
||||
const project = fixtureProject('sveltekit');
|
||||
if (!project) return;
|
||||
const resolved = resolveFramework(project.root, project.config);
|
||||
assert.equal(resolved.framework.name, 'static-html');
|
||||
assert.equal(resolved.framework.inject.kind, 'tag');
|
||||
});
|
||||
|
||||
it('adapters ask for the gitignore patterns their generated paths need', () => {
|
||||
const nuxt = fixtureProject('nuxt-vite7');
|
||||
const resolvedNuxt = resolveFramework(nuxt.root, nuxt.config);
|
||||
assert.deepEqual(frameworkIgnorePatterns(resolvedNuxt), ['plugins/impeccable-live.client.ts']);
|
||||
|
||||
const tanstack = fixtureProject('tanstack-start');
|
||||
const resolvedTanstack = resolveFramework(tanstack.root, tanstack.config);
|
||||
assert.deepEqual(
|
||||
frameworkIgnorePatterns(resolvedTanstack),
|
||||
['src/impeccable/ImpeccableLiveRoot.tsx'],
|
||||
);
|
||||
|
||||
const vite = fixtureProject('vite8-react-plain');
|
||||
assert.deepEqual(frameworkIgnorePatterns(resolveFramework(vite.root, vite.config)), []);
|
||||
});
|
||||
|
||||
it('describes adapter artifacts as created files plus patched anchors', () => {
|
||||
const tanstack = fixtureProject('tanstack-start');
|
||||
const resolved = resolveFramework(tanstack.root, tanstack.config);
|
||||
const artifacts = describeInjectArtifacts(resolved, { cwd: tanstack.root, files: [] });
|
||||
assert.deepEqual(artifacts.map((a) => [a.kind, a.path]), [
|
||||
['created', 'src/impeccable/ImpeccableLiveRoot.tsx'],
|
||||
['patched', 'src/routes/__root.tsx'],
|
||||
]);
|
||||
for (const artifact of artifacts) {
|
||||
if (artifact.kind === 'patched') {
|
||||
assert.equal(typeof PATCH_UNDOERS[artifact.patch], 'function');
|
||||
assert.ok(artifact.markers.length > 0, 'patched artifacts carry ownership markers');
|
||||
} else {
|
||||
assert.ok(artifact.marker, 'created artifacts carry an ownership marker');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('describes tag-strategy artifacts as one patch per resolved file', () => {
|
||||
const vite = fixtureProject('vite8-react-plain');
|
||||
const resolved = resolveFramework(vite.root, vite.config);
|
||||
const artifacts = describeInjectArtifacts(resolved, { cwd: vite.root, files: ['index.html', 'other.html'] });
|
||||
assert.deepEqual(artifacts.map((a) => a.path), ['index.html', 'other.html']);
|
||||
assert.ok(artifacts.every((a) => a.kind === 'patched' && a.patch === TAG_PATCH_KIND));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crash-safe injection journal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRISTINE_HTML = '<!DOCTYPE html>\n<html>\n <body>\n <h1>Hello</h1>\n </body>\n</html>\n';
|
||||
// Exactly what an inject leaves behind, so healing has to restore the original
|
||||
// bytes the same way `--remove` does.
|
||||
const INJECTED_HTML = insertTag(
|
||||
PRISTINE_HTML,
|
||||
{ insertBefore: '</body>', commentSyntax: 'html' },
|
||||
8400,
|
||||
undefined,
|
||||
'',
|
||||
);
|
||||
|
||||
const NUXT_PLUGIN_BODY = '/* impeccable-live-nuxt-plugin */\nexport default defineNuxtPlugin(() => {});\n';
|
||||
|
||||
describe('inject journal — crash recovery', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-journal-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
/** Simulate a session that wrote artifacts and was SIGKILLed before stop. */
|
||||
function stageKilledSession({ withPlugin = true, withTag = true } = {}) {
|
||||
const artifacts = [];
|
||||
if (withPlugin) {
|
||||
mkdirSync(join(tmp, 'app', 'plugins'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts'), NUXT_PLUGIN_BODY);
|
||||
artifacts.push({
|
||||
kind: 'created',
|
||||
path: 'app/plugins/impeccable-live.client.ts',
|
||||
marker: 'impeccable-live-nuxt-plugin',
|
||||
pruneTo: 'app',
|
||||
});
|
||||
}
|
||||
if (withTag) {
|
||||
writeFileSync(join(tmp, 'index.html'), INJECTED_HTML);
|
||||
artifacts.push({
|
||||
kind: 'patched',
|
||||
path: 'index.html',
|
||||
patch: TAG_PATCH_KIND,
|
||||
markers: ['impeccable-live-start', 'data-impeccable-csp-original'],
|
||||
});
|
||||
}
|
||||
recordInjection(tmp, { framework: 'nuxt', port: 8400, artifacts });
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
it('writes the journal under .impeccable/live/ so the ignore block covers it', () => {
|
||||
stageKilledSession();
|
||||
assert.equal(injectJournalPath(tmp), join(tmp, '.impeccable', 'live', 'inject-journal.json'));
|
||||
assert.ok(existsSync(injectJournalPath(tmp)));
|
||||
const journal = readInjectJournal(tmp);
|
||||
assert.equal(journal.version, 1);
|
||||
assert.equal(journal.framework, 'nuxt');
|
||||
assert.equal(journal.port, 8400);
|
||||
assert.equal(journal.appRoot, resolve(tmp));
|
||||
assert.equal(journal.artifacts.length, 2);
|
||||
});
|
||||
|
||||
it('heals a SIGKILLed session back to a clean tree', () => {
|
||||
stageKilledSession();
|
||||
|
||||
const { healed } = healInjectJournal(tmp);
|
||||
|
||||
assert.deepEqual(
|
||||
healed.map((h) => [h.path, h.action]).sort(),
|
||||
[['app/plugins/impeccable-live.client.ts', 'removed'], ['index.html', 'unpatched']].sort(),
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
|
||||
false,
|
||||
'the generated plugin is gone',
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(tmp, 'app', 'plugins')),
|
||||
false,
|
||||
'the generated plugins/ directory is pruned',
|
||||
);
|
||||
assert.equal(existsSync(join(tmp, 'app')), true, 'pruning stops at the declared boundary');
|
||||
assert.equal(
|
||||
readFileSync(join(tmp, 'index.html'), 'utf-8'),
|
||||
PRISTINE_HTML,
|
||||
'the patched entry is restored byte-for-byte, same as --remove would',
|
||||
);
|
||||
assert.equal(readInjectJournal(tmp), null, 'a fully healed journal is cleared');
|
||||
});
|
||||
|
||||
it('is idempotent — a second heal finds nothing to do', () => {
|
||||
stageKilledSession();
|
||||
healInjectJournal(tmp);
|
||||
const second = healInjectJournal(tmp);
|
||||
assert.deepEqual(second.healed, []);
|
||||
assert.deepEqual(second.kept, []);
|
||||
assert.equal(existsSync(injectJournalPath(tmp)), false);
|
||||
});
|
||||
|
||||
it('keeps artifacts the current run is about to rewrite', () => {
|
||||
stageKilledSession();
|
||||
|
||||
const { healed, kept } = healInjectJournal(tmp, {
|
||||
keep: ['app/plugins/impeccable-live.client.ts'],
|
||||
});
|
||||
|
||||
assert.deepEqual(healed.map((h) => h.path), ['index.html']);
|
||||
assert.deepEqual(kept.map((k) => k.path), ['app/plugins/impeccable-live.client.ts']);
|
||||
assert.ok(
|
||||
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
|
||||
'a kept artifact is left in place so re-injection stays byte-idempotent',
|
||||
);
|
||||
const journal = readInjectJournal(tmp);
|
||||
assert.deepEqual(journal.artifacts.map((a) => a.path), ['app/plugins/impeccable-live.client.ts']);
|
||||
});
|
||||
|
||||
it('disowns a generated file the user has since replaced', () => {
|
||||
stageKilledSession({ withTag: false });
|
||||
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
|
||||
const userBody = 'export default defineNuxtPlugin(() => { /* mine now */ });\n';
|
||||
writeFileSync(pluginPath, userBody);
|
||||
|
||||
const { healed } = healInjectJournal(tmp);
|
||||
|
||||
assert.deepEqual(healed, [], 'nothing is reported as healed');
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), userBody, 'the user file survives untouched');
|
||||
assert.equal(readInjectJournal(tmp), null, 'but we stop claiming it');
|
||||
});
|
||||
|
||||
it('leaves a patched file alone once its markers are gone', () => {
|
||||
stageKilledSession({ withPlugin: false });
|
||||
// Whitespace an undo function would normalize, with no marker left.
|
||||
const handEdited = '<html>\n\n\n <body>\n </body>\n</html>\n';
|
||||
writeFileSync(join(tmp, 'index.html'), handEdited);
|
||||
|
||||
const { healed } = healInjectJournal(tmp);
|
||||
|
||||
assert.deepEqual(healed, []);
|
||||
assert.equal(readFileSync(join(tmp, 'index.html'), 'utf-8'), handEdited);
|
||||
});
|
||||
|
||||
it('tolerates artifacts whose files are already gone', () => {
|
||||
stageKilledSession({ withTag: false });
|
||||
rmSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts'));
|
||||
|
||||
const { healed } = healInjectJournal(tmp);
|
||||
|
||||
assert.deepEqual(healed, []);
|
||||
assert.equal(existsSync(injectJournalPath(tmp)), false);
|
||||
});
|
||||
|
||||
it('no-ops without a journal, and clears cleanly', () => {
|
||||
assert.deepEqual(healInjectJournal(tmp), { healed: [], kept: [] });
|
||||
clearInjectJournal(tmp);
|
||||
assert.equal(readInjectJournal(tmp), null);
|
||||
});
|
||||
|
||||
it('drops the journal file when an injection wrote nothing', () => {
|
||||
stageKilledSession({ withTag: false });
|
||||
recordInjection(tmp, { framework: 'static-html', port: 8400, artifacts: [] });
|
||||
assert.equal(existsSync(injectJournalPath(tmp)), false);
|
||||
});
|
||||
});
|
||||
+150
-1
@@ -6,7 +6,7 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -457,3 +457,152 @@ const title = 'Test';
|
||||
assert.equal(readFileSync(pluginPath, 'utf-8'), userPlugin);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Injection journal — the CLI half. The unit-level heal semantics live in
|
||||
// tests/live-frameworks.test.mjs; these pin the two paths that actually run it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ORPHAN_PLUGIN = '/* impeccable-live-nuxt-plugin */\nexport default defineNuxtPlugin(() => {});\n';
|
||||
|
||||
function stageOrphanedSession(root) {
|
||||
mkdirSync(join(root, 'app', 'plugins'), { recursive: true });
|
||||
writeFileSync(join(root, 'app', 'plugins', 'impeccable-live.client.ts'), ORPHAN_PLUGIN);
|
||||
mkdirSync(join(root, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(root, '.impeccable', 'live', 'inject-journal.json'), JSON.stringify({
|
||||
version: 1,
|
||||
appRoot: root,
|
||||
framework: 'nuxt',
|
||||
port: 8400,
|
||||
pid: 999999,
|
||||
recordedAt: new Date().toISOString(),
|
||||
artifacts: [{
|
||||
kind: 'created',
|
||||
path: 'app/plugins/impeccable-live.client.ts',
|
||||
marker: 'impeccable-live-nuxt-plugin',
|
||||
pruneTo: 'app',
|
||||
}],
|
||||
}));
|
||||
}
|
||||
|
||||
describe('live-inject — crash-safe injection journal', () => {
|
||||
it('refuses to heal artifacts outside the project tree (review M1)', async () => {
|
||||
const { healInjectJournal } = await import('../skill/scripts/live/frameworks/journal.mjs');
|
||||
const outside = join(tmp, '..', `impeccable-victim-${Date.now()}`);
|
||||
writeFileSync(outside, 'precious');
|
||||
try {
|
||||
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'inject-journal.json'), JSON.stringify({
|
||||
version: 1,
|
||||
artifacts: [
|
||||
{ kind: 'created', path: relative(tmp, outside) },
|
||||
{ kind: 'created', path: 'unmarked.txt' },
|
||||
],
|
||||
}));
|
||||
writeFileSync(join(tmp, 'unmarked.txt'), 'no marker present');
|
||||
const { healed } = healInjectJournal(tmp, { keep: [] });
|
||||
// The escape attempt is refused and the file survives.
|
||||
assert.equal(readFileSync(outside, 'utf-8'), 'precious');
|
||||
// A created artifact without a marker is unverifiable: untouched.
|
||||
assert.equal(readFileSync(join(tmp, 'unmarked.txt'), 'utf-8'), 'no marker present');
|
||||
const actions = (healed || []).map((h) => h.action);
|
||||
assert.equal(actions.includes('removed'), false, JSON.stringify(healed));
|
||||
} finally {
|
||||
rmSync(outside, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-journal-cli-'))); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
function writeLiveConfig(root) {
|
||||
mkdirSync(join(root, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(root, '.impeccable', 'live', 'config.json'), JSON.stringify({
|
||||
files: ['index.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
}
|
||||
|
||||
it('records what an inject wrote and clears it again on remove', () => {
|
||||
writeFileSync(join(tmp, 'index.html'), '<html>\n <body>\n </body>\n</html>\n');
|
||||
writeLiveConfig(tmp);
|
||||
|
||||
runInjectDefault(tmp, ['--port', '8400']);
|
||||
const journal = JSON.parse(readFileSync(join(tmp, '.impeccable', 'live', 'inject-journal.json'), 'utf-8'));
|
||||
assert.equal(journal.port, 8400);
|
||||
assert.deepEqual(journal.artifacts.map((a) => a.path), ['index.html']);
|
||||
|
||||
runInjectDefault(tmp, ['--remove']);
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'inject-journal.json')), false);
|
||||
});
|
||||
|
||||
it('heals artifacts a SIGKILLed session left behind, on the next inject', () => {
|
||||
// The project no longer detects as Nuxt (the config file is gone), so the
|
||||
// adapter's own remove can never reach its plugin. The journal can.
|
||||
writeFileSync(join(tmp, 'index.html'), '<html>\n <body>\n </body>\n</html>\n');
|
||||
writeLiveConfig(tmp);
|
||||
stageOrphanedSession(tmp);
|
||||
|
||||
const result = runInjectDefault(tmp, ['--port', '8400']);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.healed, [{ path: 'app/plugins/impeccable-live.client.ts', action: 'removed' }]);
|
||||
assert.equal(existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')), false);
|
||||
assert.equal(existsSync(join(tmp, 'app', 'plugins')), false, 'the emptied plugins/ dir is pruned');
|
||||
assert.match(readFileSync(join(tmp, 'index.html'), 'utf-8'), /localhost:8400\/live\.js/);
|
||||
});
|
||||
|
||||
it('does not report healing when there is nothing orphaned', () => {
|
||||
writeFileSync(join(tmp, 'index.html'), '<html>\n <body>\n </body>\n</html>\n');
|
||||
writeLiveConfig(tmp);
|
||||
|
||||
const result = runInjectDefault(tmp, ['--port', '8400']);
|
||||
|
||||
assert.equal(result.healed, undefined, 'the healed key stays off the happy path');
|
||||
});
|
||||
|
||||
it('heals the appRoot journal when stop runs from the wrong directory', () => {
|
||||
execFileSync('git', ['init', '-q'], { cwd: tmp });
|
||||
writeFileSync(join(tmp, 'index.html'), '<html>\n <body>\n </body>\n</html>\n');
|
||||
writeLiveConfig(tmp);
|
||||
stageOrphanedSession(tmp);
|
||||
// enterLiveRoot follows this manifest back to the app root.
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'roots.json'), JSON.stringify({
|
||||
version: 1,
|
||||
appRoot: tmp,
|
||||
repoRoot: tmp,
|
||||
contextRoot: null,
|
||||
sessionRoot: join(tmp, '.impeccable', 'live'),
|
||||
resolvedFrom: 'cwd',
|
||||
}));
|
||||
const nested = join(tmp, 'packages', 'deep');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
|
||||
const result = runInjectDefault(nested, ['--remove']);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.healed, [{ path: 'app/plugins/impeccable-live.client.ts', action: 'removed' }]);
|
||||
assert.equal(
|
||||
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
|
||||
false,
|
||||
'a stop issued from a nested directory still cleans the app root',
|
||||
);
|
||||
assert.equal(existsSync(join(tmp, '.impeccable', 'live', 'inject-journal.json')), false);
|
||||
});
|
||||
|
||||
it('leaves the journal alone on --check', () => {
|
||||
writeFileSync(join(tmp, 'index.html'), '<html>\n <body>\n </body>\n</html>\n');
|
||||
writeLiveConfig(tmp);
|
||||
stageOrphanedSession(tmp);
|
||||
|
||||
const result = runInjectDefault(tmp, ['--check']);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(
|
||||
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
|
||||
'--check is read-only; healing belongs to the inject run',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -17,6 +17,13 @@ function withTempProject(fn) {
|
||||
finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function snapshotDir(dir) {
|
||||
return readdirSync(dir).sort().map((name) => {
|
||||
const filePath = join(dir, name);
|
||||
return { name, mtimeMs: statSync(filePath).mtimeMs, body: readFileSync(filePath, 'utf-8') };
|
||||
});
|
||||
}
|
||||
|
||||
function runJson(script, args, cwd) {
|
||||
const out = execFileSync(process.execPath, [script, ...args], { cwd, encoding: 'utf-8' });
|
||||
return JSON.parse(out);
|
||||
@@ -34,6 +41,26 @@ describe('live recovery CLI commands', () => {
|
||||
assert.match(status.recoveryHint, /Start live-server/);
|
||||
}));
|
||||
|
||||
it('leaves every session file untouched while reporting status', () => withTempProject((cwd) => {
|
||||
const store = createLiveSessionStore({ cwd });
|
||||
store.appendEvent({ type: 'generate', id: 'cli-readonly-1', action: 'impeccable', count: 2, pageUrl: '/', element: { outerHTML: '<section>Hero</section>' } });
|
||||
store.appendEvent({ type: 'variants_ready', id: 'cli-readonly-1', file: 'src/App.jsx', arrivedVariants: 2 });
|
||||
|
||||
const sessionsDir = join(cwd, '.impeccable', 'live', 'sessions');
|
||||
const before = snapshotDir(sessionsDir);
|
||||
|
||||
// Both commands run in their own process against a session a live server
|
||||
// may still own. Reporting on it must not rewrite it.
|
||||
runJson(STATUS_SCRIPT, [], cwd);
|
||||
runJson(RESUME_SCRIPT, ['--id', 'cli-readonly-1'], cwd);
|
||||
|
||||
assert.deepEqual(
|
||||
snapshotDir(sessionsDir),
|
||||
before,
|
||||
'event=live_recovery.read_mutates_state actor=agent operation=status_and_resume risk=reporting_rewrites_session_files',
|
||||
);
|
||||
}));
|
||||
|
||||
it('resumes the pending event and reports the next safe agent action', () => withTempProject((cwd) => {
|
||||
const store = createLiveSessionStore({ cwd });
|
||||
store.appendEvent({ type: 'generate', id: 'cli-recover-2', action: 'impeccable', count: 2, pageUrl: '/', element: { outerHTML: '<section>Hero</section>' } });
|
||||
@@ -100,6 +127,48 @@ describe('live recovery CLI commands', () => {
|
||||
);
|
||||
}));
|
||||
|
||||
it('reports render truth so published is never read as rendered', () => withTempProject((cwd) => {
|
||||
const store = createLiveSessionStore({ cwd });
|
||||
store.appendEvent({ type: 'generate', id: 'cli-render-1', action: 'impeccable', count: 3, pageUrl: '/', element: { outerHTML: '<section>Hero</section>' } });
|
||||
store.appendEvent({ type: 'agent_done', id: 'cli-render-1', file: 'src/App.svelte' });
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'cli-render-1', variant: 1 });
|
||||
|
||||
const resume = runJson(RESUME_SCRIPT, ['--id', 'cli-render-1'], cwd);
|
||||
assert.equal(resume.render.renderState, 'mounted');
|
||||
assert.deepEqual(resume.render.mountedVariants, [1]);
|
||||
assert.deepEqual(resume.render.mountFailures, []);
|
||||
|
||||
const status = runJson(STATUS_SCRIPT, [], cwd);
|
||||
assert.equal(status.render[0].renderState, 'mounted');
|
||||
assert.deepEqual(status.render[0].mountedVariants, [1]);
|
||||
}));
|
||||
|
||||
it('turns a browser mount failure into an actionable next step', () => withTempProject((cwd) => {
|
||||
const store = createLiveSessionStore({ cwd });
|
||||
store.appendEvent({ type: 'generate', id: 'cli-render-2', action: 'impeccable', count: 3, pageUrl: '/', element: { outerHTML: '<section>Hero</section>' } });
|
||||
store.appendEvent({ type: 'agent_done', id: 'cli-render-2', file: 'src/App.svelte' });
|
||||
store.appendEvent({
|
||||
type: 'variant_mount_failed',
|
||||
id: 'cli-render-2',
|
||||
variant: 2,
|
||||
url: '/preview/v2.svelte',
|
||||
error: 'Failed to fetch dynamically imported module',
|
||||
});
|
||||
|
||||
const resume = runJson(RESUME_SCRIPT, ['--id', 'cli-render-2'], cwd);
|
||||
assert.equal(resume.render.renderState, 'failed');
|
||||
assert.match(
|
||||
resume.nextAction,
|
||||
/failed to mount variant 2 from \/preview\/v2\.svelte/,
|
||||
'event=live_resume.mount_failure_action actor=agent operation=recover_session risk=agent_thinks_variants_are_on_screen expected=named failing variant and url actual=' + resume.nextAction,
|
||||
);
|
||||
assert.match(resume.nextAction, /variant_mount_failed/);
|
||||
assert.match(resume.nextAction, /--reply EVENT_ID done --file/);
|
||||
|
||||
const status = runJson(STATUS_SCRIPT, [], cwd);
|
||||
assert.match(status.recoveryHint, /failed to mount variant 2/);
|
||||
}));
|
||||
|
||||
it('marks a session completed through the canonical completion command', () => withTempProject((cwd) => {
|
||||
const store = createLiveSessionStore({ cwd });
|
||||
store.appendEvent({ type: 'generate', id: 'cli-recover-3', action: 'impeccable', count: 1, pageUrl: '/', element: { outerHTML: '<p>Copy</p>' } });
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
discoverAppCandidates,
|
||||
findGitRoot,
|
||||
resolveLiveRoots,
|
||||
resolveRoots,
|
||||
writeRootsManifest,
|
||||
} from '../skill/scripts/live/roots.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOTS_MODULE = join(__dirname, '..', 'skill', 'scripts', 'live', 'roots.mjs');
|
||||
|
||||
function write(root, rel, content = '') {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
describe('live roots resolution', () => {
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setupPlainNestedApp() {
|
||||
// The agent-reviews shape: a git repo whose root is a CLI package (no dev
|
||||
// config, no workspaces) with the served app nested in website/.
|
||||
mkdirSync(join(tmp, '.git'), { recursive: true });
|
||||
write(tmp, 'package.json', JSON.stringify({ name: 'cli-package' }));
|
||||
write(tmp, 'PRODUCT.md', '# product');
|
||||
write(tmp, 'DESIGN.md', '# design');
|
||||
write(tmp, 'website/package.json', JSON.stringify({ name: 'website' }));
|
||||
write(tmp, 'website/svelte.config.js', 'export default {};');
|
||||
write(tmp, 'website/vite.config.js', 'export default {};');
|
||||
write(tmp, 'website/src/routes/+page.svelte', '<h1>hi</h1>');
|
||||
}
|
||||
|
||||
it('roots a targeted file at the nested app, context at the git root', () => {
|
||||
setupPlainNestedApp();
|
||||
const { manifest } = resolveRoots({
|
||||
cwd: tmp,
|
||||
targetPath: join(tmp, 'website/src/routes/+page.svelte'),
|
||||
});
|
||||
assert.equal(manifest.appRoot, join(tmp, 'website'));
|
||||
assert.equal(manifest.repoRoot, tmp);
|
||||
assert.equal(manifest.contextRoot, tmp);
|
||||
assert.equal(manifest.productPath, join(tmp, 'PRODUCT.md'));
|
||||
assert.equal(manifest.designPath, join(tmp, 'DESIGN.md'));
|
||||
assert.equal(manifest.sessionRoot, join(tmp, 'website', '.impeccable', 'live'));
|
||||
});
|
||||
|
||||
it('auto-picks a single nested app when booted from the repo root without a target', () => {
|
||||
setupPlainNestedApp();
|
||||
const { manifest, selection } = resolveRoots({ cwd: tmp });
|
||||
assert.equal(selection, undefined);
|
||||
assert.equal(manifest.appRoot, join(tmp, 'website'));
|
||||
assert.match(manifest.resolvedFrom, /^candidate:/);
|
||||
});
|
||||
|
||||
it('asks for a selection when several nested apps exist', () => {
|
||||
setupPlainNestedApp();
|
||||
write(tmp, 'admin/package.json', JSON.stringify({ name: 'admin' }));
|
||||
write(tmp, 'admin/vite.config.ts', 'export default {};');
|
||||
const { manifest, selection } = resolveRoots({ cwd: tmp });
|
||||
assert.equal(manifest, undefined);
|
||||
assert.equal(selection.candidates.length, 2);
|
||||
assert.deepEqual(selection.candidates.map((c) => c.name).sort(), ['admin', 'website']);
|
||||
});
|
||||
|
||||
it('resolves context files independently across levels', () => {
|
||||
setupPlainNestedApp();
|
||||
rmSync(join(tmp, 'DESIGN.md'));
|
||||
write(tmp, 'website/DESIGN.md', '# child design');
|
||||
const { manifest } = resolveRoots({
|
||||
cwd: tmp,
|
||||
targetPath: join(tmp, 'website/src/routes/+page.svelte'),
|
||||
});
|
||||
assert.equal(manifest.designPath, join(tmp, 'website', 'DESIGN.md'));
|
||||
assert.equal(manifest.productPath, join(tmp, 'PRODUCT.md'));
|
||||
});
|
||||
|
||||
it('treats a live-configured directory as an app root without a dev config', () => {
|
||||
mkdirSync(join(tmp, '.git'), { recursive: true });
|
||||
write(tmp, 'site/.impeccable/live/config.json', '{"files":["index.html"]}');
|
||||
write(tmp, 'site/index.html', '<html></html>');
|
||||
const { manifest } = resolveRoots({ cwd: tmp, targetPath: join(tmp, 'site/index.html') });
|
||||
assert.equal(manifest.appRoot, join(tmp, 'site'));
|
||||
});
|
||||
|
||||
it('stays at cwd when no app markers exist anywhere', () => {
|
||||
write(tmp, 'notes.txt', 'nothing here');
|
||||
const { manifest } = resolveRoots({ cwd: tmp });
|
||||
assert.equal(manifest.appRoot, tmp);
|
||||
assert.equal(manifest.repoRoot, tmp);
|
||||
assert.equal(manifest.resolvedFrom, 'fallback');
|
||||
});
|
||||
|
||||
it('does not ascend above cwd without a git boundary', () => {
|
||||
write(tmp, 'vite.config.js', 'export default {};');
|
||||
const nested = join(tmp, 'deep', 'inner');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const { manifest } = resolveRoots({ cwd: nested });
|
||||
// tmp has a dev config but there is no git root, so the walk must not
|
||||
// climb out of the starting directory.
|
||||
assert.equal(manifest.appRoot, nested);
|
||||
});
|
||||
|
||||
it('persists a manifest and finds it again from anywhere in the repo', () => {
|
||||
setupPlainNestedApp();
|
||||
const { manifest } = resolveRoots({
|
||||
cwd: tmp,
|
||||
targetPath: join(tmp, 'website/src/routes/+page.svelte'),
|
||||
});
|
||||
writeRootsManifest(manifest);
|
||||
|
||||
// From deep inside the app: found by upward walk.
|
||||
const fromApp = resolveLiveRoots(join(tmp, 'website/src/routes'));
|
||||
assert.equal(fromApp.source, 'persisted');
|
||||
assert.equal(fromApp.manifest.appRoot, join(tmp, 'website'));
|
||||
|
||||
// From the repo root: found via the pointer.
|
||||
const fromRepo = resolveLiveRoots(tmp);
|
||||
assert.equal(fromRepo.source, 'pointer');
|
||||
assert.equal(fromRepo.manifest.appRoot, join(tmp, 'website'));
|
||||
});
|
||||
|
||||
it('ignores a stale manifest that claims a different appRoot', () => {
|
||||
setupPlainNestedApp();
|
||||
write(tmp, 'website/.impeccable/live/roots.json', JSON.stringify({
|
||||
version: 1,
|
||||
appRoot: join(tmp, 'elsewhere'),
|
||||
}));
|
||||
const res = resolveLiveRoots(join(tmp, 'website'));
|
||||
assert.equal(res.source, 'fresh');
|
||||
assert.equal(res.manifest.appRoot, join(tmp, 'website'));
|
||||
});
|
||||
|
||||
it('finds the git root through intermediate directories', () => {
|
||||
mkdirSync(join(tmp, '.git'), { recursive: true });
|
||||
const deep = join(tmp, 'a', 'b', 'c');
|
||||
mkdirSync(deep, { recursive: true });
|
||||
assert.equal(findGitRoot(deep), tmp);
|
||||
});
|
||||
|
||||
it('discovers app candidates below common monorepo layouts', () => {
|
||||
mkdirSync(join(tmp, '.git'), { recursive: true });
|
||||
write(tmp, 'apps/web/next.config.js', 'module.exports = {};');
|
||||
write(tmp, 'apps/api/package.json', '{"name":"api"}');
|
||||
write(tmp, 'packages/ui/package.json', '{"name":"ui"}');
|
||||
const candidates = discoverAppCandidates(tmp);
|
||||
assert.deepEqual(candidates, [join(tmp, 'apps', 'web')]);
|
||||
});
|
||||
|
||||
it('enterLiveRoot moves a process onto the persisted appRoot', () => {
|
||||
setupPlainNestedApp();
|
||||
const { manifest } = resolveRoots({
|
||||
cwd: tmp,
|
||||
targetPath: join(tmp, 'website/src/routes/+page.svelte'),
|
||||
});
|
||||
writeRootsManifest(manifest);
|
||||
const res = spawnSync(process.execPath, [
|
||||
'-e',
|
||||
`import(${JSON.stringify(ROOTS_MODULE)}).then((m) => { m.enterLiveRoot(); console.log(process.cwd()); });`,
|
||||
], { cwd: tmp, encoding: 'utf-8' });
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.equal(realpathSync(res.stdout.trim()), join(tmp, 'website'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: walk bounds', () => {
|
||||
it('does not climb past a target outside the cwd git repo (m5)', () => {
|
||||
const outer = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-outer-')));
|
||||
try {
|
||||
mkdirSync(join(outer, 'repo', '.git'), { recursive: true });
|
||||
writeFileSync(join(outer, 'vite.config.js'), 'export default {};');
|
||||
const loose = join(outer, 'loose', 'inner', 'sub');
|
||||
mkdirSync(loose, { recursive: true });
|
||||
const { manifest } = resolveRoots({ cwd: join(outer, 'repo'), targetPath: loose });
|
||||
// The dev config at `outer` sits above the target's own tree with no
|
||||
// git boundary; the walk must not adopt it.
|
||||
assert.notEqual(manifest.appRoot, outer);
|
||||
assert.equal(manifest.appRoot, loose);
|
||||
} finally {
|
||||
rmSync(outer, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+267
-3
@@ -15,6 +15,10 @@ import {
|
||||
getLiveServerPath,
|
||||
getLiveSessionsDir,
|
||||
} from '../skill/scripts/lib/impeccable-paths.mjs';
|
||||
import {
|
||||
removeAllSvelteComponentSessions,
|
||||
sweepInactiveSvelteComponentSessions,
|
||||
} from '../skill/scripts/live/svelte-component.mjs';
|
||||
|
||||
const REPO_ROOT = process.cwd();
|
||||
const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs');
|
||||
@@ -2448,7 +2452,7 @@ colors: {}
|
||||
token: server.token,
|
||||
type: 'agent_phase',
|
||||
id: 'a1b2c3e1',
|
||||
phase: 'first_variant_generating',
|
||||
phase: 'first_reviewable',
|
||||
owner: 'live-agent',
|
||||
}),
|
||||
});
|
||||
@@ -2456,11 +2460,30 @@ colors: {}
|
||||
const message = new TextDecoder().decode((await reader.read()).value);
|
||||
controller.abort();
|
||||
assert.match(message, /"type":"agent_phase"/);
|
||||
assert.match(message, /"phase":"first_variant_generating"/);
|
||||
assert.match(message, /"phase":"first_reviewable"/);
|
||||
const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
|
||||
assert.equal(polled.type, 'timeout');
|
||||
const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3e1.snapshot.json'), 'utf-8'));
|
||||
assert.ok(snapshot.generationTimings.first_variant_generating?.at);
|
||||
assert.ok(snapshot.generationTimings.first_reviewable?.at);
|
||||
});
|
||||
|
||||
it('rejects an agent phase outside the protocol enum', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'agent_phase',
|
||||
id: 'a1b2c3e2',
|
||||
phase: 'first_variant_generating',
|
||||
}),
|
||||
});
|
||||
assert.equal(
|
||||
res.status,
|
||||
400,
|
||||
'event=live_server.unknown_agent_phase actor=agent operation=agent_phase risk=unrenderable_phase_journaled expected=400 actual=' + res.status,
|
||||
);
|
||||
assert.match(await res.text(), /unknown phase/);
|
||||
});
|
||||
|
||||
it('streams Svelte component checkpoints as progressive preview updates', async () => {
|
||||
@@ -3465,4 +3488,245 @@ colors: {}
|
||||
body: JSON.stringify({ token: server.token, id, type: 'complete', sourceEventType: 'accept' }),
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mount acknowledgements
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function postEvent(body) {
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, ...body }),
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
async function readStatus() {
|
||||
return (await fetch(`http://localhost:${server.port}/status?token=${server.token}`)).json();
|
||||
}
|
||||
|
||||
it('journals variant_mounted without handing it to the agent', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'bb11cc22';
|
||||
await postEvent({
|
||||
type: 'generate', id, action: 'impeccable', count: 2, pageUrl: '/',
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'SECTION' },
|
||||
});
|
||||
const leased = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(leased.type, 'generate');
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
|
||||
const ack = await postEvent({ type: 'variant_mounted', id, variant: 1, url: '/preview/v1.svelte' });
|
||||
assert.equal(ack.status, 200);
|
||||
|
||||
const status = await readStatus();
|
||||
assert.equal(
|
||||
status.pendingEvents.some((event) => event.id === id && event.type === 'variant_mounted'),
|
||||
false,
|
||||
'event=live_server.mount_ack actor=browser operation=post_variant_mounted risk=agent_woken_for_nothing expected=no queued event actual=queued',
|
||||
);
|
||||
const session = status.activeSessions.find((entry) => entry.id === id);
|
||||
assert.deepEqual(session.mountedVariants, [1]);
|
||||
assert.equal(session.renderState, 'mounted');
|
||||
});
|
||||
|
||||
it('queues variant_mount_failed for the agent and clears it on a done reply', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'cc33dd44';
|
||||
await postEvent({
|
||||
type: 'generate', id, action: 'impeccable', count: 2, pageUrl: '/',
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'SECTION' },
|
||||
});
|
||||
const leased = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(leased.type, 'generate');
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
|
||||
const failure = await postEvent({
|
||||
type: 'variant_mount_failed',
|
||||
id,
|
||||
variant: 2,
|
||||
url: '/preview/v2.svelte',
|
||||
error: 'Failed to fetch dynamically imported module',
|
||||
});
|
||||
assert.equal(failure.status, 200);
|
||||
|
||||
const delivered = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=1000&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(
|
||||
delivered.type,
|
||||
'variant_mount_failed',
|
||||
'event=live_server.mount_failure actor=browser operation=post_variant_mount_failed risk=silent_render_failure expected=agent receives failure actual=' + delivered.type,
|
||||
);
|
||||
assert.equal(delivered.variant, 2);
|
||||
assert.equal(delivered.url, '/preview/v2.svelte');
|
||||
|
||||
const beforeReply = await readStatus();
|
||||
const failedSession = beforeReply.activeSessions.find((entry) => entry.id === id);
|
||||
assert.equal(failedSession.renderState, 'failed');
|
||||
assert.equal(failedSession.mountFailures[0].variant, 2);
|
||||
|
||||
// The agent republishes and replies `done`. Without the source-event
|
||||
// inference fix this ack looks for a retired `generate`, leaves the failure
|
||||
// queued forever, and the same event is redelivered on every poll.
|
||||
const reply = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', file: 'src/App.svelte' }),
|
||||
});
|
||||
assert.equal(reply.status, 200);
|
||||
|
||||
const afterReply = await readStatus();
|
||||
assert.equal(
|
||||
afterReply.pendingEvents.some((event) => event.id === id && event.type === 'variant_mount_failed'),
|
||||
false,
|
||||
'a done reply must retire the mount failure it answered',
|
||||
);
|
||||
});
|
||||
|
||||
it('rebroadcasts done over SSE so the browser re-runs its injection', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'dd55ee66';
|
||||
await postEvent({
|
||||
type: 'generate', id, action: 'impeccable', count: 1, pageUrl: '/',
|
||||
element: { outerHTML: '<section>Hero</section>', tagName: 'SECTION' },
|
||||
});
|
||||
const leased = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(leased.type, 'generate');
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', sourceEventType: 'generate' }),
|
||||
});
|
||||
await postEvent({
|
||||
type: 'variant_mount_failed', id, variant: 1, url: '/preview/v1.svelte', error: 'boom',
|
||||
});
|
||||
const delivered = await (await fetch(
|
||||
`http://localhost:${server.port}/poll?token=${server.token}&timeout=1000&leaseMs=60000`,
|
||||
)).json();
|
||||
assert.equal(delivered.type, 'variant_mount_failed');
|
||||
|
||||
const sse = await fetch(`http://localhost:${server.port}/events?token=${server.token}`);
|
||||
const reader = sse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
await readSseUntil(reader, decoder, 'connected', 3);
|
||||
|
||||
await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, id, type: 'done', file: 'src/App.svelte' }),
|
||||
});
|
||||
const text = await readSseUntil(reader, decoder, '"type":"done"', 8);
|
||||
assert.match(text, /"type":"done"/);
|
||||
assert.match(text, new RegExp('"id":"' + id + '"'));
|
||||
await reader.cancel().catch(() => {});
|
||||
});
|
||||
|
||||
it('reports the browser summary fields a storage-less page needs to rehydrate', async () => {
|
||||
await drainPolls(server);
|
||||
const id = 'ee77ff88';
|
||||
await postEvent({
|
||||
type: 'generate', id, action: 'impeccable', count: 3, pageUrl: '/pricing',
|
||||
element: { outerHTML: '<section>Plans</section>', tagName: 'SECTION' },
|
||||
});
|
||||
await drainPolls(server);
|
||||
await postEvent({ type: 'variant_mounted', id, variant: 2 });
|
||||
|
||||
const status = await readStatus();
|
||||
const session = status.activeSessions.find((entry) => entry.id === id);
|
||||
assert.equal(session.pageUrl, '/pricing');
|
||||
assert.equal(session.expectedVariants, 3);
|
||||
assert.equal(session.renderState, 'mounted');
|
||||
assert.deepEqual(session.mountedVariants, [2]);
|
||||
assert.deepEqual(session.mountFailures, []);
|
||||
});
|
||||
|
||||
it('rejects malformed mount acknowledgements at the edge', async () => {
|
||||
const bad = await postEvent({ type: 'variant_mounted', id: 'ff99aa00', variant: 0 });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.match(bad.body.error, /variant/);
|
||||
const noUrl = await postEvent({ type: 'variant_mount_failed', id: 'ff99aa00', variant: 1, error: 'boom' });
|
||||
assert.equal(noUrl.status, 400);
|
||||
assert.match(noUrl.body.error, /url required/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview-tree cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Svelte component preview tree cleanup', () => {
|
||||
let tmp;
|
||||
|
||||
function seed(sessionIds) {
|
||||
const root = join(tmp, 'node_modules', '.impeccable-live');
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(join(root, '__runtime.js'), 'export const mount = () => {};\n', 'utf-8');
|
||||
for (const id of sessionIds) {
|
||||
mkdirSync(join(root, id), { recursive: true });
|
||||
writeFileSync(join(root, id, 'manifest.json'), JSON.stringify({ id }), 'utf-8');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-sweep-')));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removeAllSvelteComponentSessions takes the runtime shim and the parent dir with it', () => {
|
||||
const root = seed(['sess-a', 'sess-b']);
|
||||
removeAllSvelteComponentSessions(tmp);
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), false, '__runtime.js must not survive a full sweep');
|
||||
assert.equal(existsSync(root), false, 'the .impeccable-live parent dir must not survive a full sweep');
|
||||
});
|
||||
|
||||
it('sweepInactiveSvelteComponentSessions keeps active sessions and the tree they need', () => {
|
||||
const root = seed(['sess-a', 'sess-b']);
|
||||
const result = sweepInactiveSvelteComponentSessions(['sess-a'], tmp);
|
||||
assert.deepEqual(result.removed, ['sess-b']);
|
||||
assert.deepEqual(result.kept, ['sess-a']);
|
||||
assert.equal(result.removedRoot, false);
|
||||
assert.equal(existsSync(join(root, 'sess-a', 'manifest.json')), true, 'active session must survive');
|
||||
assert.equal(existsSync(join(root, 'sess-b')), false, 'orphaned session must be removed');
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), true, 'runtime shim stays while a session is active');
|
||||
});
|
||||
|
||||
it('sweepInactiveSvelteComponentSessions clears the whole tree when nothing is active', () => {
|
||||
const root = seed(['sess-a', 'sess-b']);
|
||||
const result = sweepInactiveSvelteComponentSessions([], tmp);
|
||||
assert.deepEqual(result.removed.sort(), ['sess-a', 'sess-b']);
|
||||
assert.equal(result.removedRoot, true);
|
||||
assert.equal(existsSync(join(root, '__runtime.js')), false, '__runtime.js must be gone');
|
||||
assert.equal(existsSync(root), false, 'the .impeccable-live parent dir must be gone');
|
||||
});
|
||||
|
||||
it('both sweeps are no-ops when the tree was never created', () => {
|
||||
const clean = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-sweep-empty-')));
|
||||
try {
|
||||
removeAllSvelteComponentSessions(clean);
|
||||
const result = sweepInactiveSvelteComponentSessions(['whatever'], clean);
|
||||
assert.deepEqual(result, { removed: [], removedRoot: false, kept: [] });
|
||||
} finally {
|
||||
rmSync(clean, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, rmSync, appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
@@ -393,4 +394,339 @@ describe('live-session-store', () => {
|
||||
assert.equal(snapshot.generationPhase, 'source_ready');
|
||||
assert.deepEqual(snapshot.generationTimings.source_ready, { at: 1234, durationMs: 42 });
|
||||
});
|
||||
|
||||
describe('derived-state cache', () => {
|
||||
/**
|
||||
* Counts reads of one journal while `body` runs. The store patches nothing
|
||||
* to make this observable: it calls fs.readFileSync on the module object
|
||||
* this test also holds, so a replay is directly countable.
|
||||
*/
|
||||
function countJournalReads(journalPath, body) {
|
||||
const original = fs.readFileSync;
|
||||
let reads = 0;
|
||||
fs.readFileSync = (target, ...rest) => {
|
||||
if (String(target) === journalPath) reads++;
|
||||
return original(target, ...rest);
|
||||
};
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
fs.readFileSync = original;
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
function countWrites(body) {
|
||||
const originalWrite = fs.writeFileSync;
|
||||
const originalAppend = fs.appendFileSync;
|
||||
let writes = 0;
|
||||
fs.writeFileSync = (...args) => { writes++; return originalWrite(...args); };
|
||||
fs.appendFileSync = (...args) => { writes++; return originalAppend(...args); };
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
fs.writeFileSync = originalWrite;
|
||||
fs.appendFileSync = originalAppend;
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
it('appends without replaying the journal it just wrote', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'append-cost' });
|
||||
store.appendEvent({ type: 'generate', id: 'append-cost', count: 3, element: { classes: ['hero'] } });
|
||||
const journalPath = join(getLiveSessionsDir(tmp), 'append-cost.jsonl');
|
||||
|
||||
const reads = countJournalReads(journalPath, () => {
|
||||
for (let revision = 1; revision <= 25; revision++) {
|
||||
store.appendEvent({ type: 'checkpoint', id: 'append-cost', revision, phase: 'cycling', visibleVariant: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
reads,
|
||||
0,
|
||||
'event=live_session_store.append_replay actor=server operation=append_event risk=quadratic_journal_replay expected=0 journal reads actual=' + reads,
|
||||
);
|
||||
const snapshot = store.getSnapshot('append-cost');
|
||||
assert.equal(snapshot.checkpointRevision, 25);
|
||||
assert.equal(snapshot.expectedVariants, 3);
|
||||
});
|
||||
|
||||
it('keeps a full replay equivalent to the incremental result', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'equivalence' });
|
||||
store.appendEvent({ type: 'generate', id: 'equivalence', count: 3, element: { classes: ['hero'] } });
|
||||
store.appendEvent({ type: 'checkpoint', id: 'equivalence', revision: 4, phase: 'cycling', visibleVariant: 2 });
|
||||
store.appendEvent({ type: 'checkpoint', id: 'equivalence', revision: 1, phase: 'cycling', visibleVariant: 9 });
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'equivalence', variant: 2 });
|
||||
store.appendEvent({ type: 'variants_ready', id: 'equivalence', file: 'src/App.jsx', arrivedVariants: 3 });
|
||||
const incremental = store.getSnapshot('equivalence');
|
||||
|
||||
// A store that has never seen this session has nothing cached and no
|
||||
// usable snapshot file for it either, so it takes the replay path.
|
||||
rmSync(join(getLiveSessionsDir(tmp), 'equivalence.snapshot.json'));
|
||||
const replayed = createLiveSessionStore({ cwd: tmp }).getSnapshot('equivalence');
|
||||
|
||||
assert.deepEqual(
|
||||
{ ...replayed, updatedAt: null },
|
||||
{ ...incremental, updatedAt: null },
|
||||
'event=live_session_store.incremental_drift actor=store operation=apply_event risk=cached_snapshot_diverges_from_journal',
|
||||
);
|
||||
});
|
||||
|
||||
it('answers reads without writing anything', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'read-only' });
|
||||
store.appendEvent({ type: 'generate', id: 'read-only', count: 2, element: { classes: ['hero'] } });
|
||||
const snapshotPath = join(getLiveSessionsDir(tmp), 'read-only.snapshot.json');
|
||||
const before = { stat: statSync(snapshotPath).mtimeMs, body: readFileSync(snapshotPath, 'utf-8') };
|
||||
|
||||
// A separate instance stands in for live-status / live-resume, which run
|
||||
// in their own process against a session the server owns.
|
||||
const reader = createLiveSessionStore({ cwd: tmp });
|
||||
const writes = countWrites(() => {
|
||||
reader.getSnapshot('read-only');
|
||||
reader.listActiveSessions();
|
||||
reader.getSnapshot('read-only', { includeCompleted: true });
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
writes,
|
||||
0,
|
||||
'event=live_session_store.read_writes actor=agent operation=get_snapshot risk=status_command_mutates_session_state expected=0 writes actual=' + writes,
|
||||
);
|
||||
assert.equal(statSync(snapshotPath).mtimeMs, before.stat);
|
||||
assert.equal(readFileSync(snapshotPath, 'utf-8'), before.body);
|
||||
});
|
||||
|
||||
it('reuses a snapshot file that still describes the journal, and skips one that does not', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'hydrate' });
|
||||
store.appendEvent({ type: 'generate', id: 'hydrate', count: 3, element: { classes: ['hero'] } });
|
||||
store.appendEvent({ type: 'variants_ready', id: 'hydrate', file: 'src/App.jsx', arrivedVariants: 3 });
|
||||
const journalPath = join(getLiveSessionsDir(tmp), 'hydrate.jsonl');
|
||||
const snapshotPath = join(getLiveSessionsDir(tmp), 'hydrate.snapshot.json');
|
||||
|
||||
const fresh = createLiveSessionStore({ cwd: tmp });
|
||||
const hydratedReads = countJournalReads(journalPath, () => {
|
||||
assert.equal(fresh.getSnapshot('hydrate').arrivedVariants, 3);
|
||||
});
|
||||
assert.equal(
|
||||
hydratedReads,
|
||||
0,
|
||||
'event=live_session_store.list_replay actor=agent operation=list_active_sessions risk=every_status_call_replays_every_journal expected=0 journal reads actual=' + hydratedReads,
|
||||
);
|
||||
|
||||
// A snapshot file whose recorded journal size no longer matches is not
|
||||
// evidence of anything; the journal wins.
|
||||
const stale = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
stale.arrivedVariants = 99;
|
||||
stale.__journalBytes = 1;
|
||||
writeFileSync(snapshotPath, JSON.stringify(stale, null, 2));
|
||||
const afterStale = createLiveSessionStore({ cwd: tmp }).getSnapshot('hydrate');
|
||||
assert.equal(
|
||||
afterStale.arrivedVariants,
|
||||
3,
|
||||
'event=live_session_store.stale_snapshot_trusted actor=store operation=get_snapshot risk=cache_outranks_journal expected=3 actual=' + afterStale.arrivedVariants,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a corrupt snapshot file instead of failing the read', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'corrupt-cache' });
|
||||
store.appendEvent({ type: 'generate', id: 'corrupt-cache', count: 2, element: { classes: ['hero'] } });
|
||||
writeFileSync(join(getLiveSessionsDir(tmp), 'corrupt-cache.snapshot.json'), '{ not json');
|
||||
|
||||
const snapshot = createLiveSessionStore({ cwd: tmp }).getSnapshot('corrupt-cache');
|
||||
assert.equal(snapshot.phase, 'generate_requested');
|
||||
assert.equal(snapshot.expectedVariants, 2);
|
||||
});
|
||||
|
||||
it('converges two store instances writing the same session from different processes', () => {
|
||||
const server = createLiveSessionStore({ cwd: tmp, sessionId: 'two-writers' });
|
||||
const publisher = createLiveSessionStore({ cwd: tmp, sessionId: 'two-writers' });
|
||||
|
||||
server.appendEvent({ type: 'generate', id: 'two-writers', count: 3, element: { classes: ['hero'] } });
|
||||
// The publisher's first append must see the server's event, not start
|
||||
// from an empty snapshot with sequence 1 again.
|
||||
publisher.appendEvent({ type: 'checkpoint', id: 'two-writers', revision: 2, phase: 'cycling', arrivedVariants: 1 });
|
||||
server.appendEvent({ type: 'variant_mounted', id: 'two-writers', variant: 1 });
|
||||
publisher.appendEvent({ type: 'variants_ready', id: 'two-writers', file: 'src/App.jsx', arrivedVariants: 3 });
|
||||
|
||||
const fromServer = server.getSnapshot('two-writers');
|
||||
const fromPublisher = publisher.getSnapshot('two-writers');
|
||||
assert.deepEqual({ ...fromServer, updatedAt: null }, { ...fromPublisher, updatedAt: null });
|
||||
assert.equal(fromServer.arrivedVariants, 3);
|
||||
assert.equal(fromServer.expectedVariants, 3);
|
||||
assert.deepEqual(fromServer.mountedVariants, [1]);
|
||||
|
||||
const seqs = readFileSync(join(getLiveSessionsDir(tmp), 'two-writers.jsonl'), 'utf-8')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line).seq);
|
||||
assert.deepEqual(
|
||||
seqs,
|
||||
[1, 2, 3, 4],
|
||||
'event=live_session_store.seq_collision actor=publisher operation=append_event risk=two_processes_reuse_sequence_numbers actual=' + JSON.stringify(seqs),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-derives when another process appends behind a cached reader', () => {
|
||||
const reader = createLiveSessionStore({ cwd: tmp, sessionId: 'external-append' });
|
||||
const writer = createLiveSessionStore({ cwd: tmp, sessionId: 'external-append' });
|
||||
writer.appendEvent({ type: 'generate', id: 'external-append', count: 3, element: { classes: ['hero'] } });
|
||||
assert.equal(reader.getSnapshot('external-append').phase, 'generate_requested');
|
||||
|
||||
writer.appendEvent({ type: 'accept', id: 'external-append', variantId: '2' });
|
||||
assert.equal(
|
||||
reader.getSnapshot('external-append').phase,
|
||||
'accept_requested',
|
||||
'event=live_session_store.cache_staleness actor=agent operation=get_snapshot risk=reader_serves_pre_accept_state',
|
||||
);
|
||||
assert.equal(reader.getSnapshot('external-append').visibleVariant, 2);
|
||||
});
|
||||
|
||||
it('flushes the snapshot file on demand without appending an event', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'flushable' });
|
||||
store.appendEvent({ type: 'generate', id: 'flushable', count: 1, element: { classes: ['hero'] } });
|
||||
const snapshotPath = join(getLiveSessionsDir(tmp), 'flushable.snapshot.json');
|
||||
rmSync(snapshotPath);
|
||||
|
||||
const flushed = createLiveSessionStore({ cwd: tmp }).flush('flushable');
|
||||
assert.equal(flushed.phase, 'generate_requested');
|
||||
const onDisk = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
assert.equal(onDisk.phase, 'generate_requested');
|
||||
const journalBytes = statSync(join(getLiveSessionsDir(tmp), 'flushable.jsonl')).size;
|
||||
assert.equal(onDisk.__journalBytes, journalBytes);
|
||||
});
|
||||
|
||||
it('keeps journal bookkeeping out of the snapshot it hands callers', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'no-meta' });
|
||||
const returned = store.appendEvent({ type: 'generate', id: 'no-meta', count: 1, element: { classes: ['hero'] } });
|
||||
assert.equal(returned.__journalBytes, undefined);
|
||||
assert.equal(returned.__nextSeq, undefined);
|
||||
|
||||
const hydrated = createLiveSessionStore({ cwd: tmp }).getSnapshot('no-meta');
|
||||
assert.equal(hydrated.__journalBytes, undefined);
|
||||
assert.equal(hydrated.__nextSeq, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render truth', () => {
|
||||
function seedPublishedSession(store, id) {
|
||||
store.appendEvent({ type: 'generate', id, count: 3, element: { classes: ['hero'] } });
|
||||
store.appendEvent({ type: 'variants_ready', id, file: 'src/App.svelte' });
|
||||
}
|
||||
|
||||
it('starts a published cycle as pending, before any browser acknowledgement', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-pending' });
|
||||
seedPublishedSession(store, 'render-pending');
|
||||
|
||||
const snapshot = store.getSnapshot('render-pending');
|
||||
assert.equal(
|
||||
snapshot.arrivedVariants,
|
||||
3,
|
||||
'the published-variant backfill stays for compatibility',
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.renderState,
|
||||
'pending',
|
||||
'event=live_session_store.render_truth actor=agent operation=publish risk=published_mistaken_for_rendered expected=pending actual=' + snapshot.renderState,
|
||||
);
|
||||
assert.deepEqual(snapshot.mountedVariants, []);
|
||||
assert.deepEqual(snapshot.mountFailures, []);
|
||||
});
|
||||
|
||||
it('records distinct mounted variants and flips renderState to mounted', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-mounted' });
|
||||
seedPublishedSession(store, 'render-mounted');
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-mounted', variant: 2 });
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-mounted', variant: 2 });
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-mounted', variant: 1 });
|
||||
|
||||
const snapshot = store.getSnapshot('render-mounted');
|
||||
assert.deepEqual(snapshot.mountedVariants, [1, 2]);
|
||||
assert.equal(snapshot.renderState, 'mounted');
|
||||
assert.equal(snapshot.diagnostics.length, 0, 'mount acks are known event types');
|
||||
});
|
||||
|
||||
it('reports failed while nothing has mounted, and caps the failure history at five', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-failed' });
|
||||
seedPublishedSession(store, 'render-failed');
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
store.appendEvent({
|
||||
type: 'variant_mount_failed',
|
||||
id: 'render-failed',
|
||||
variant: i,
|
||||
url: '/preview/v' + i + '.svelte',
|
||||
error: 'import failed ' + i,
|
||||
at: 1000 + i,
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = store.getSnapshot('render-failed');
|
||||
assert.equal(snapshot.renderState, 'failed');
|
||||
assert.equal(snapshot.mountFailures.length, 5);
|
||||
assert.deepEqual(
|
||||
snapshot.mountFailures.map((failure) => failure.variant),
|
||||
[3, 4, 5, 6, 7],
|
||||
'the newest failures are the ones worth keeping',
|
||||
);
|
||||
assert.deepEqual(snapshot.mountFailures[4], {
|
||||
variant: 7,
|
||||
url: '/preview/v7.svelte',
|
||||
error: 'import failed 7',
|
||||
at: 1007,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets one successful mount outrank earlier failures', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-mixed' });
|
||||
seedPublishedSession(store, 'render-mixed');
|
||||
store.appendEvent({
|
||||
type: 'variant_mount_failed', id: 'render-mixed', variant: 2, url: '/v2.svelte', error: 'boom',
|
||||
});
|
||||
assert.equal(store.getSnapshot('render-mixed').renderState, 'failed');
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-mixed', variant: 1 });
|
||||
|
||||
const snapshot = store.getSnapshot('render-mixed');
|
||||
assert.equal(snapshot.renderState, 'mounted');
|
||||
assert.equal(snapshot.mountFailures.length, 1, 'the failure stays on the record');
|
||||
});
|
||||
|
||||
it('resets render truth when a new generate starts the next cycle', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-reset' });
|
||||
seedPublishedSession(store, 'render-reset');
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-reset', variant: 1 });
|
||||
store.appendEvent({
|
||||
type: 'variant_mount_failed', id: 'render-reset', variant: 3, url: '/v3.svelte', error: 'boom',
|
||||
});
|
||||
store.appendEvent({ type: 'generate', id: 'render-reset', count: 2, element: { classes: ['hero'] } });
|
||||
|
||||
const snapshot = store.getSnapshot('render-reset');
|
||||
assert.deepEqual(snapshot.mountedVariants, []);
|
||||
assert.deepEqual(snapshot.mountFailures, []);
|
||||
assert.equal(snapshot.renderState, null);
|
||||
});
|
||||
|
||||
it('survives a journal replay in a fresh process', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-replay' });
|
||||
seedPublishedSession(store, 'render-replay');
|
||||
store.appendEvent({
|
||||
type: 'variant_mount_failed', id: 'render-replay', variant: 1, url: '/v1.svelte', error: 'boom',
|
||||
});
|
||||
|
||||
const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'render-replay' });
|
||||
const snapshot = restarted.getSnapshot('render-replay');
|
||||
assert.equal(snapshot.renderState, 'failed');
|
||||
assert.equal(snapshot.mountFailures[0].url, '/v1.svelte');
|
||||
});
|
||||
|
||||
it('diagnoses a mount ack with no usable variant instead of trusting it', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'render-bad' });
|
||||
seedPublishedSession(store, 'render-bad');
|
||||
store.appendEvent({ type: 'variant_mounted', id: 'render-bad', variant: 0 });
|
||||
|
||||
const snapshot = store.getSnapshot('render-bad');
|
||||
assert.deepEqual(snapshot.mountedVariants, []);
|
||||
assert.equal(snapshot.renderState, 'pending');
|
||||
assert.equal(snapshot.diagnostics.some((d) => d.error === 'malformed_mount_ack'), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parse, compile } from 'svelte/compiler';
|
||||
import {
|
||||
analyzeSvelteMarkup,
|
||||
buildPropsScriptV2,
|
||||
collectRootIdentifiers,
|
||||
derivePropName,
|
||||
restoreSvelteMarkup,
|
||||
} from '../skill/scripts/live/svelte-ast.mjs';
|
||||
|
||||
const PIT_BOARD = `<ol class="stages" data-active={activeId}>
|
||||
{#each stages as stage, i}
|
||||
<li class="stage">
|
||||
<span class="label">{stage.label}</span>
|
||||
<p class="detail">{stage.detail}</p>
|
||||
{#if stage.active}<span class="dot"></span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
<p class="footer">{footerNote}</p>
|
||||
</ol>`;
|
||||
|
||||
describe('svelte AST scaffolding', () => {
|
||||
it('turns a free each collection into one structured prop and keeps the loop intact', () => {
|
||||
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const collection = res.contract.find((c) => c.kind === 'collection');
|
||||
assert.equal(collection.prop, 'stages');
|
||||
assert.equal(collection.expr, 'stages');
|
||||
// The loop body survives verbatim: bound expressions are not props.
|
||||
assert.match(res.markupWithProps, /\{#each stages as stage, i\}/);
|
||||
assert.match(res.markupWithProps, /\{stage\.label\}/);
|
||||
assert.match(res.markupWithProps, /\{#if stage\.active\}/);
|
||||
// No prop entries for bound expressions.
|
||||
assert.equal(res.contract.some((c) => c.expr.includes('stage.')), false);
|
||||
});
|
||||
|
||||
it('records the item hydration description for each blocks', () => {
|
||||
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
|
||||
const collection = res.contract.find((c) => c.kind === 'collection');
|
||||
assert.equal(collection.item.rootTag, 'li');
|
||||
assert.deepEqual(collection.item.rootClasses, ['stage']);
|
||||
assert.deepEqual(collection.item.textSlots.map((s) => s.key), ['label', 'detail']);
|
||||
});
|
||||
|
||||
it('extracts free text and attribute expressions as props', () => {
|
||||
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
|
||||
const props = Object.fromEntries(res.contract.map((c) => [c.expr, c]));
|
||||
assert.equal(props['footerNote'].kind, 'text');
|
||||
assert.equal(props['activeId'].kind, 'text');
|
||||
assert.match(res.markupWithProps, /data-active=\{activeId\}/);
|
||||
});
|
||||
|
||||
it('names collection props from member tails and restores them precisely', () => {
|
||||
const src = `<ul>{#each data.stages as s}<li>{s.name}</li>{/each}</ul>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
assert.match(res.markupWithProps, /\{#each stages as s\}/);
|
||||
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
|
||||
assert.equal(restored.ok, true);
|
||||
assert.equal(restored.markup, src);
|
||||
});
|
||||
|
||||
it('classifies event handler expressions as handler props', () => {
|
||||
const src = `<button onclick={handleGo} class="go">{label}</button>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const handler = res.contract.find((c) => c.expr === 'handleGo');
|
||||
assert.equal(handler.kind, 'handler');
|
||||
});
|
||||
|
||||
it('deduplicates repeated expressions and disambiguates name collisions', () => {
|
||||
const src = `<div><h1>{a.title}</h1><h2>{b.title}</h2><p>{a.title}</p></div>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const names = res.contract.map((c) => c.prop);
|
||||
assert.deepEqual(names, ['title', 'title2']);
|
||||
});
|
||||
|
||||
it('supports independent nested each blocks but rejects bound nested ones', () => {
|
||||
const independent = `<div>{#each rows as r}<p>{r.x}</p>{/each}{#each cols as c}<p>{c.y}</p>{/each}</div>`;
|
||||
const okRes = analyzeSvelteMarkup(independent, parse);
|
||||
assert.equal(okRes.ok, true, okRes.reason);
|
||||
assert.equal(okRes.contract.filter((c) => c.kind === 'collection').length, 2);
|
||||
|
||||
const nested = `<ul>{#each groups as g}<li>{#each g.items as item}<span>{item.n}</span>{/each}</li>{/each}</ul>`;
|
||||
const badRes = analyzeSvelteMarkup(nested, parse);
|
||||
assert.equal(badRes.ok, false);
|
||||
assert.match(badRes.reason, /nested/);
|
||||
});
|
||||
|
||||
it('falls back for constructs a detached preview cannot support', () => {
|
||||
const cases = [
|
||||
[`<div><Card title={t} /></div>`, /component tag/],
|
||||
[`<input bind:value={query} />`, /bind:/],
|
||||
[`<div use:tooltip>{t}</div>`, /use:/],
|
||||
[`<div>{#await p}<p>wait</p>{:then v}<p>{v}</p>{/await}</div>`, /await/],
|
||||
[`<div><script>let x = 1;<\/script><p>{t}</p></div>`, /script/],
|
||||
[`<div {...rest}>{t}</div>`, /spread/],
|
||||
];
|
||||
for (const [src, reason] of cases) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, false, `expected fallback for: ${src}`);
|
||||
assert.match(res.reason, reason, src);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats class directives as condition props', () => {
|
||||
const src = `<div class:active={isActive}><span>{label}</span></div>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const cond = res.contract.find((c) => c.expr === 'isActive');
|
||||
assert.equal(cond.kind, 'condition');
|
||||
});
|
||||
|
||||
it('round-trips every supported corpus snippet', () => {
|
||||
const corpus = [
|
||||
PIT_BOARD,
|
||||
`<ul>{#each data.stages as s, i (s.id)}<li>{s.name}</li>{/each}</ul>`,
|
||||
`<section>{#if user.loggedIn}<p>{user.name}</p>{:else}<p>guest</p>{/if}</section>`,
|
||||
`<div>{@html bodyHtml}</div>`,
|
||||
`<article>{#each posts as post}<h2>{post.title}</h2>{#if post.pinned}<em>pinned</em>{/if}{/each}</article>`,
|
||||
`<p title={hint}>{copy} and {copy}</p>`,
|
||||
`<div>{@const label = row.name}<span>{label}</span></div>`,
|
||||
`<nav>{#each links as link}<a href={link.href}>{link.text}</a>{/each}</nav>`,
|
||||
];
|
||||
for (const src of corpus) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, `${res.reason} in: ${src}`);
|
||||
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
|
||||
assert.equal(restored.ok, true, src);
|
||||
assert.equal(restored.markup, src, `round trip failed for: ${src}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates a props script that compiles with real svelte', () => {
|
||||
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
|
||||
const component = `${buildPropsScriptV2(res.contract)}\n${res.markupWithProps}\n<style>\n .stage { display: flex; }\n</style>\n`;
|
||||
const compiled = compile(component, { generate: 'client' });
|
||||
assert.ok(compiled.js.code.length > 0);
|
||||
const fatal = (compiled.warnings || []).filter((w) => /error/i.test(w.code || ''));
|
||||
assert.deepEqual(fatal, []);
|
||||
});
|
||||
|
||||
it('collectRootIdentifiers skips member properties and object keys', () => {
|
||||
const ast = parse(`<p>{fmt(user.name, { width: cols })}</p>`, { modern: true });
|
||||
const tag = ast.fragment.nodes[0].fragment.nodes[0];
|
||||
const roots = collectRootIdentifiers(tag.expression);
|
||||
assert.deepEqual([...roots].sort(), ['cols', 'fmt', 'user']);
|
||||
});
|
||||
|
||||
it('derivePropName picks stable tails', () => {
|
||||
assert.equal(derivePropName('stages'), 'stages');
|
||||
assert.equal(derivePropName('data.stages'), 'stages');
|
||||
assert.equal(derivePropName('rows[0].label'), 'label');
|
||||
assert.equal(derivePropName('a + b'), 'value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyed each blocks', () => {
|
||||
it('records a keyField for member keys and keeps the key in the scaffold', () => {
|
||||
const src = `<ul>{#each expenses as expense, i (expense.id)}<li class="row"><strong>{expense.name}</strong></li>{/each}</ul>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const collection = res.contract.find((c) => c.kind === 'collection');
|
||||
assert.equal(collection.item.keyField, 'id');
|
||||
assert.match(res.markupWithProps, /\(expense\.id\)/);
|
||||
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
|
||||
assert.equal(restored.markup, src);
|
||||
});
|
||||
|
||||
it('needs no keyField when the key is the item or the index', () => {
|
||||
for (const src of [
|
||||
`<ul>{#each rows as r (r)}<li>{r.x}</li>{/each}</ul>`,
|
||||
`<ul>{#each rows as r, i (i)}<li>{r.x}</li>{/each}</ul>`,
|
||||
]) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, `${res.reason} in ${src}`);
|
||||
const collection = res.contract.find((c) => c.kind === 'collection');
|
||||
assert.equal(collection.item.keyField, undefined, src);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back for keys a detached preview cannot hydrate distinctly', () => {
|
||||
const cases = [
|
||||
[`<ul>{#each rows as r (globalThing)}<li>{r.x}</li>{/each}</ul>`, /not derived from the loop item/],
|
||||
[`<ul>{#each rows as r (makeKey(r))}<li>{r.x}</li>{/each}</ul>`, /complex each key/],
|
||||
[`<ul>{#each rows as r (r.name)}<li>{r.name}</li>{/each}</ul>`, /also a displayed field/],
|
||||
];
|
||||
for (const [src, reason] of cases) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, false, `expected fallback for ${src}`);
|
||||
assert.match(res.reason, reason, src);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: reserved prop names', () => {
|
||||
it('never derives a JS reserved word as a prop name (M5)', () => {
|
||||
for (const [src, expected] of [
|
||||
[`<div>{item.class}</div>`, 'classValue'],
|
||||
[`<div>{cfg.default}</div>`, 'defaultValue'],
|
||||
[`<div>{a.for}</div>`, 'forValue'],
|
||||
]) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
assert.equal(res.contract[0].prop, expected, src);
|
||||
// The generated stub must actually compile.
|
||||
const stub = `${buildPropsScriptV2(res.contract)}\n${res.markupWithProps}\n`;
|
||||
const compiled = compile(stub, { generate: 'client' });
|
||||
assert.ok(compiled.js.code.length > 0, src);
|
||||
// And restore back to the original expression.
|
||||
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
|
||||
assert.equal(restored.markup, src);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
extractMatchingSourceCss,
|
||||
findSvelteComponentManifest,
|
||||
inlineSvelteComponentAccept,
|
||||
mergeCssIntoSvelteSource,
|
||||
reindentPreservingStructure,
|
||||
scaffoldSvelteComponentSession,
|
||||
} from '../skill/scripts/live/svelte-component.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_NODE_MODULES = join(__dirname, '..', 'node_modules');
|
||||
|
||||
const ROUTE_SOURCE = `<script>
|
||||
let stages = [
|
||||
{ label: 'Review', detail: 'Bots comment', active: true },
|
||||
{ label: 'Resolve', detail: 'Agent fixes', active: false },
|
||||
];
|
||||
let footerNote = 'All quiet.';
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<ol class="pit-board">
|
||||
{#each stages as stage, i}
|
||||
<li class="stage">
|
||||
<span class="label">{stage.label}</span>
|
||||
<p class="detail">{stage.detail}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p class="footer">{footerNote}</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.pit-board {
|
||||
border-top: 1px solid #333;
|
||||
display: flex;
|
||||
}
|
||||
.stage { padding: 8px; }
|
||||
.footer { color: gray; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
function write(root, rel, content) {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, content);
|
||||
}
|
||||
|
||||
describe('svelte component scaffold + accept pipeline', () => {
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-svelte-accept-')));
|
||||
// The scaffolder resolves the app's svelte compiler; link this repo's.
|
||||
mkdirSync(join(tmp, 'node_modules'), { recursive: true });
|
||||
try {
|
||||
symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp, 'node_modules', 'svelte'), 'dir');
|
||||
} catch {
|
||||
cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp, 'node_modules', 'svelte'), { recursive: true });
|
||||
}
|
||||
write(tmp, 'package.json', JSON.stringify({ name: 'app', dependencies: { svelte: '^5' } }));
|
||||
write(tmp, 'src/routes/+page.svelte', ROUTE_SOURCE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function scaffold(id = 'testacc1') {
|
||||
// The picked element spans the <ol> block: lines 10-17 (1-indexed).
|
||||
const originalLines = ROUTE_SOURCE.split('\n').slice(9, 17);
|
||||
assert.match(originalLines[0], /<ol/);
|
||||
assert.match(originalLines[originalLines.length - 1], /<\/ol>/);
|
||||
return scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count: 2,
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
sourceStartLine: 10,
|
||||
sourceEndLine: 17,
|
||||
originalLines,
|
||||
cwd: tmp,
|
||||
});
|
||||
}
|
||||
|
||||
it('scaffolds a v2 contract with the each collection as one structured prop', () => {
|
||||
const session = scaffold();
|
||||
assert.equal(session.fallback, undefined);
|
||||
assert.equal(session.manifest.contractVersion, 2);
|
||||
const collection = session.propContract.find((c) => c.kind === 'collection');
|
||||
assert.equal(collection.prop, 'stages');
|
||||
assert.equal(collection.item.rootTag, 'li');
|
||||
const v1 = readFileSync(join(tmp, session.componentDir, 'v1.svelte'), 'utf-8');
|
||||
assert.match(v1, /\{#each stages as stage, i\}/);
|
||||
assert.match(v1, /\{stage\.label\}/);
|
||||
assert.match(v1, /let \{ stages = \[\] \} = \$props\(\)/);
|
||||
// Stub CSS is seeded from the route's matching rules.
|
||||
assert.match(v1, /border-top: 1px solid #333/);
|
||||
});
|
||||
|
||||
it('falls back to source-preview for markup with component tags', () => {
|
||||
const res = scaffoldSvelteComponentSession({
|
||||
id: 'fallb1',
|
||||
count: 3,
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
sourceStartLine: 1,
|
||||
sourceEndLine: 1,
|
||||
originalLines: ['<Card title={x} />'],
|
||||
cwd: tmp,
|
||||
});
|
||||
assert.equal(res.fallback, 'source-preview');
|
||||
assert.match(res.reason, /component tag/);
|
||||
});
|
||||
|
||||
it('accept merges CSS instead of appending: superseded rules are replaced, dead branches pruned', () => {
|
||||
const session = scaffold('acc2');
|
||||
// Agent authors variant 1: arrows instead of divider borders, one param.
|
||||
write(tmp, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
/** @type {{ stages?: Array<Record<string, unknown>> }} */
|
||||
let { stages = [] } = $props();
|
||||
</script>
|
||||
|
||||
<ol class="pit-board">
|
||||
{#each stages as stage, i}
|
||||
<li class="stage">
|
||||
<span class="label">{stage.label}</span>
|
||||
<p class="detail">{stage.detail}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.pit-board {
|
||||
display: flex;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.stage { padding: calc(var(--p-depth, 6px) + 2px); }
|
||||
:global([data-p-density="airy"]) .stage { margin: 16px; }
|
||||
:global([data-p-density="snug"]) .stage { margin: 4px; }
|
||||
</style>
|
||||
`);
|
||||
write(tmp, join(session.componentDir, 'params.json'), JSON.stringify({
|
||||
1: [
|
||||
{ id: 'depth', kind: 'range', min: 0, max: 20, step: 1, default: 6, label: 'Depth' },
|
||||
{ id: 'density', kind: 'steps', default: 'airy', label: 'Density', options: [
|
||||
{ value: 'airy', label: 'Airy' }, { value: 'snug', label: 'Snug' },
|
||||
] },
|
||||
],
|
||||
}));
|
||||
|
||||
const manifest = findSvelteComponentManifest('acc2', tmp);
|
||||
const result = inlineSvelteComponentAccept(manifest, 1, { depth: 10, density: 'snug' }, tmp);
|
||||
assert.equal(result.handled, true, result.error);
|
||||
|
||||
const out = readFileSync(join(tmp, 'src/routes/+page.svelte'), 'utf-8');
|
||||
// Loop restored with original expressions, one each block only.
|
||||
assert.equal(out.split('{#each stages as stage, i}').length - 1, 1);
|
||||
// Superseded divider border is GONE (replaced, not shadowed).
|
||||
assert.doesNotMatch(out, /border-top: 1px solid #333/);
|
||||
assert.match(out, /clip-path/);
|
||||
// Exactly one .pit-board rule.
|
||||
assert.equal(out.split('.pit-board {').length - 1, 1);
|
||||
// Range baked with paren-aware substitution.
|
||||
assert.match(out, /padding: calc\(10 \+ 2px\)/);
|
||||
// Steps: chosen branch folded into the .stage rule, other branch dropped,
|
||||
// no data-p attributes anywhere.
|
||||
assert.match(out, /margin: 4px/);
|
||||
assert.equal(out.split(/\.stage \{/).length - 1, 1);
|
||||
assert.doesNotMatch(out, /margin: 16px/);
|
||||
assert.doesNotMatch(out, /data-p-/);
|
||||
assert.doesNotMatch(out, /var\(--p-/);
|
||||
// The untouched .footer rule survives.
|
||||
assert.match(out, /\.footer \{ color: gray; \}/);
|
||||
// Self-check reports clean.
|
||||
assert.equal(result.verify.clean, true, JSON.stringify(result.verify.findings));
|
||||
});
|
||||
|
||||
it('preserves the variant markup indentation structure', () => {
|
||||
const session = scaffold('acc3');
|
||||
write(tmp, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
let { stages = [] } = $props();
|
||||
</script>
|
||||
|
||||
<ol class="pit-board">
|
||||
{#each stages as stage, i}
|
||||
<li class="stage">
|
||||
<div class="deep">
|
||||
<span class="label">{stage.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.deep { display: block; }
|
||||
</style>
|
||||
`);
|
||||
const manifest = findSvelteComponentManifest('acc3', tmp);
|
||||
const result = inlineSvelteComponentAccept(manifest, 1, null, tmp);
|
||||
assert.equal(result.handled, true, result.error);
|
||||
const out = readFileSync(join(tmp, 'src/routes/+page.svelte'), 'utf-8');
|
||||
const lines = out.split('\n');
|
||||
const deepIdx = lines.findIndex((l) => l.includes('<div class="deep">'));
|
||||
const labelIdx = lines.findIndex((l) => l.includes('span class="label"'));
|
||||
const deepIndent = lines[deepIdx].match(/^\s*/)[0].length;
|
||||
const labelIndent = lines[labelIdx].match(/^\s*/)[0].length;
|
||||
// Nested structure survives: label sits deeper than its parent div.
|
||||
assert.equal(labelIndent > deepIndent, true, `expected nesting, got ${deepIndent} vs ${labelIndent}`);
|
||||
});
|
||||
|
||||
it('reindentPreservingStructure keeps relative depth', () => {
|
||||
const out = reindentPreservingStructure([' <a>', ' <b>', ' </a>'], ' ');
|
||||
assert.deepEqual(out, [' <a>', ' <b>', ' </a>']);
|
||||
});
|
||||
|
||||
it('mergeCssIntoSvelteSource creates a style block when none exists', () => {
|
||||
const { text } = mergeCssIntoSvelteSource('<div class="x">hi</div>', '.x { color: red; }');
|
||||
assert.match(text, /<style>\n\s+\.x \{ color: red; \}\n<\/style>/);
|
||||
});
|
||||
|
||||
it('extractMatchingSourceCss picks only rules that style the selection', () => {
|
||||
const css = extractMatchingSourceCss(ROUTE_SOURCE, '<ol class="pit-board"><li class="stage">x</li></ol>');
|
||||
assert.match(css, /\.pit-board/);
|
||||
assert.match(css, /\.stage/);
|
||||
assert.doesNotMatch(css, /\.footer/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user