mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 10:06:54 +03:00
Merge pull request #433 from pbakaus/live-v2-rewrite
Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept
This commit is contained in:
@@ -6,9 +6,16 @@ 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 }}
|
||||
# Scheduled runs get their own group: the 07:00 UTC nightly and a push to
|
||||
# main share github.ref, and cancel-in-progress would let them kill each
|
||||
# other mid-run.
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'schedule' && 'nightly' || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -157,14 +164,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 +234,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,9 +159,15 @@ 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.
|
||||
**LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`. Default provider/model: OpenAI `gpt-5.6-terra` at medium reasoning effort (a frontier tier, matching what drives real live sessions); Anthropic and DeepSeek remain selectable via `IMPECCABLE_E2E_LLM_PROVIDER`. Requires the selected provider's key in env (`OPENAI_API_KEY` by default); the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL` and the effort with `IMPECCABLE_E2E_LLM_EFFORT`. 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.
|
||||
|
||||
Adding a new fixture is a matter of cloning a directory under `tests/framework-fixtures/`, swapping the source files, and writing a `fixture.json`. See `tests/framework-fixtures/README.md` for the full schema.
|
||||
|
||||
|
||||
@@ -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.37", "", { "dependencies": { "@ai-sdk/gateway": "4.0.28", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw=="],
|
||||
|
||||
"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.1653615", "", {}, "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA=="],
|
||||
|
||||
"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.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
|
||||
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-12
@@ -5,21 +5,39 @@ import { DEFAULT_SUITES, matchesSuiteTriggers } from './test-suites.mjs';
|
||||
|
||||
const eventName = process.env.GITHUB_EVENT_NAME || '';
|
||||
const localNoChanges = !eventName && !process.env.CI_CHANGED_FILES;
|
||||
const changedFiles = localNoChanges ? [] : getChangedFiles();
|
||||
// The nightly schedule exists for exactly one thing: the full live-e2e
|
||||
// matrix. A schedule event has no diff base, so the change-detection path
|
||||
// degenerates to "everything changed"; without this guard that would flip on
|
||||
// every file-triggered opt-in suite, including the ones that bill LLM APIs
|
||||
// (skill-behavior, accept-cleanup, deepseek), every single night.
|
||||
const isSchedule = eventName === 'schedule';
|
||||
const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles();
|
||||
const forceDeterministic = localNoChanges || eventName === 'push' || eventName === 'workflow_dispatch';
|
||||
const forceOptIn = eventName === 'workflow_dispatch';
|
||||
|
||||
const plan = {
|
||||
core: true,
|
||||
detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles),
|
||||
live: forceDeterministic || matchesSuiteTriggers('live', changedFiles),
|
||||
framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles),
|
||||
cli_remote_e2e: forceOptIn,
|
||||
live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles),
|
||||
live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles),
|
||||
skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles),
|
||||
live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles),
|
||||
};
|
||||
const plan = isSchedule
|
||||
? {
|
||||
core: true,
|
||||
detector: true,
|
||||
live: true,
|
||||
framework: true,
|
||||
cli_remote_e2e: false,
|
||||
live_e2e: true,
|
||||
live_e2e_accept_cleanup: false,
|
||||
skill_behavior: false,
|
||||
live_svelte_adapter_deepseek: false,
|
||||
}
|
||||
: {
|
||||
core: true,
|
||||
detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles),
|
||||
live: forceDeterministic || matchesSuiteTriggers('live', changedFiles),
|
||||
framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles),
|
||||
cli_remote_e2e: forceOptIn,
|
||||
live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles),
|
||||
live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles),
|
||||
skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles),
|
||||
live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles),
|
||||
};
|
||||
|
||||
writeGithubOutputs(plan);
|
||||
printSummary(plan, changedFiles);
|
||||
|
||||
@@ -125,6 +125,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',
|
||||
@@ -141,6 +142,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',
|
||||
@@ -151,10 +153,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',
|
||||
@@ -172,7 +177,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: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
One-time live-mode project setup. Loaded from [live.md](live.md) only when `live.mjs` reports `config_missing` / `config_invalid`, when `configDrift` needs handling, or when the config lacks `cspChecked`. Not part of the per-session hot path.
|
||||
|
||||
## Write the config
|
||||
|
||||
Create the file at the `path` the boot reported (default `.impeccable/live/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target: **the HTML files the browser actually loads**, not necessarily source (tracked vs generated does not matter here; wrap has its own generated-file guard). Entries are literal paths or globs. `exclude` (optional) skips files a `files` glob would otherwise include (email templates, demo fixtures). `cspChecked` records that the CSP step below has run; absent on first setup.
|
||||
|
||||
**Hard-excluded paths (cannot be overridden):** `**/node_modules/**` and `**/.git/**`; injecting there would instrument third-party code.
|
||||
|
||||
**Glob syntax:** `**` matches any number of segments (including zero), `*` matches within a segment, `?` matches one character. Paths are project-root-relative with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
|
||||
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` glob over the served dir | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works); `insertAfter` matches after a line instead. For multi-page sites prefer a glob so new pages are picked up automatically. For sites whose pages are rebuilt by a generator, the inject survives only until the next regeneration: re-run `live.mjs` after each build (accept is unaffected; it writes true source via the fallback flow).
|
||||
|
||||
**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 artifacts a crash or wrong-directory stop left behind. 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 them and routes to a dedicated adapter (SvelteKit: dev-only root component from `+layout.svelte`; Nuxt: dev-only `.client.ts` plugin; TanStack Start: a generated dev-only `ImpeccableLiveRoot` component in `__root`). The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA takes the baseline Vite path.
|
||||
|
||||
## Config drift
|
||||
|
||||
On every boot the project is scanned for HTML files under common page roots (`public/`, `src/`, `app/`, `pages/`) that the resolved `files` list does not cover; they surface as `configDrift.orphans` with a hint. Tell the user once per session which files are uncovered and offer to add them or switch `files` to a glob. Never auto-update the config; the user decides. `configDrift` is `null` when there is no drift.
|
||||
|
||||
## CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this whole section; the user was already asked once.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output `{ shape, signals }`; the shape names the *patch mechanism*, so one template covers many frameworks:
|
||||
|
||||
- **`null`**: no CSP; write the config with `cspChecked: true` and stop here.
|
||||
- **`append-arrays`**: CSP as structured directive arrays; auto-patchable (monorepo helpers with `additionalScriptSrc`/`additionalConnectSrc`, SvelteKit `kit.csp.directives`, Nuxt `nuxt-security`).
|
||||
- **`append-string`**: CSP as a literal value string; auto-patchable (inline `next.config.*` `headers()`, Nuxt `routeRules`).
|
||||
- **`middleware`** / **`meta-tag`**: detected but not auto-patched. Show the user the detected files, ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
### Consent prompt (use this phrasing)
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400`: the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2-5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, note that live will not work until the allowance is added manually, and still write `cspChecked: true` (the question has been asked). On "yes": apply the shape's patch below, then write `cspChecked: true`.
|
||||
|
||||
### append-arrays
|
||||
|
||||
Declare near the top of the file that holds the CSP arrays, then append `...__impeccableLiveDev` to the script-src and connect-src arrays:
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Per-framework: Next.js + monorepo helper: edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` / `additionalConnectSrc`. SvelteKit: `svelte.config.js`, `kit.csp.directives['script-src']` and `['connect-src']`. Nuxt + nuxt-security: `nuxt.config.*`, `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`. Reference outputs: `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts`, `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js`. Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is applied; just mark `cspChecked: true`.
|
||||
|
||||
### append-string
|
||||
|
||||
Two-point patch: declare a dev-only string, interpolate it into the CSP value at both directives (leading space so it concatenates cleanly; convert literals to template strings as part of the edit):
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
- `script-src 'self' 'unsafe-inline'` becomes `` `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}` ``
|
||||
- `connect-src 'self'` becomes `` `connect-src 'self'${__impeccableLiveDev}` ``
|
||||
|
||||
Per-framework: Next.js inline `headers()` in `next.config.*`; Nuxt `routeRules['/**'].headers['Content-Security-Policy']` in `nuxt.config.*`. Reference outputs: `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js`, `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the user said "no" to the CSP patch and later reports live not working: their dev CSP blocks `http://localhost:8400`. Delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`; setup asks again.
|
||||
|
||||
After setup, re-run `live.mjs`.
|
||||
+113
-521
@@ -2,7 +2,7 @@ Interactive live variant mode: select elements in the browser, pick a design act
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser.
|
||||
A running dev server with HMR (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser. If the dev server's default port is busy, the app is very likely ALREADY running; probe the default URL before spawning a second server.
|
||||
|
||||
<codex>
|
||||
Codex: run live helper commands, the app dev server, and any dependency-installing setup with `sandbox_permissions: "require_escalated"` from the start; live mode depends on localhost and package-manager network access that the sandbox blocks.
|
||||
@@ -10,46 +10,29 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
|
||||
|
||||
## The contract (read once)
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
Execute in order. No step skipped, no step reordered. Every tool output in live mode may carry an `_instructions` field: it is the authoritative next step for that exact situation, with real ids and paths substituted; when it conflicts with your recollection of this document, `_instructions` wins.
|
||||
|
||||
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 and persists it in `.impeccable/live/roots.json`; every helper re-anchors to that manifest at startup (a wrong cwd cannot fork session state), PRODUCT.md / DESIGN.md are discovered upward to the git root, and relative helper args like `--file` resolve against the app root.
|
||||
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=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
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=`. The global bar's **Impeccable mark** dims with a pulsing amber dot when nothing is polling `/poll`; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants; `--reply done`; poll again. Generate in this thread: you already hold the project's tokens and layout. The overlay preview IS the verification channel; do not screenshot, re-render, or QA variants between generate and accept. Apply craft-floor's contrast, spacing, and type floors by construction as you write; full verification runs once at accept on the chosen variant.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work; `--reply steer_done`; poll again. No pickup ack.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges delivery, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts stay recoverable until `live-complete.mjs --id EVENT_ID` runs. Finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The journal under `.impeccable/live/sessions/` is canonical and replays unacknowledged work after a helper restart; the injected `live.js` re-attaches when the page reopens. Fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free while you generate and publish in it. Do not block the shell.
|
||||
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
|
||||
- **Codex**: run the default one-shot poll in a **yielded foreground exec session**. Do not suffix it with `&`, use `--stream`, or leave Live without an active foreground poll. Handle every event in the main task; after each handler/reply, restart the foreground poll.
|
||||
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout); the harness notifies you on completion. Do not block the shell.
|
||||
- **Cursor**: **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|manual_edit_apply|variant_mount_failed|prefetch|exit)"`; handle, `--reply`, restart the poll. Do **not** use `--stream` on Cursor (measured ~5s pickup vs sub-second one-shot).
|
||||
- **Codex**: default one-shot poll in a **yielded foreground exec session**. No `&`, no `--stream`, never leave Live without an active foreground poll. Starting the poll is not enough: SERVICE it (keep reading the exec session until it returns an event). Never announce "waiting for the user" and idle; a yielded poll nobody reads is a dead session, and the user's Go sits unanswered.
|
||||
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns when a shell exits.
|
||||
|
||||
Generation delivery policy:
|
||||
- **Default (Cursor and other harnesses):** keep the established atomic single-edit delivery. Do not switch a harness to progressive until its poll loop is known not to block on the extra publish calls. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
|
||||
Delivery policy: atomic single-edit delivery everywhere; do not switch a harness to progressive publishing unless its poll loop is known not to block on the extra calls.
|
||||
|
||||
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
`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).
|
||||
|
||||
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
|
||||
|
||||
## Poll loop
|
||||
|
||||
**Default (portable, all harnesses):**
|
||||
|
||||
```
|
||||
LOOP:
|
||||
node {{scripts_path}}/live-poll.mjs # default long timeout; no --timeout=
|
||||
@@ -61,254 +44,143 @@ 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
|
||||
```
|
||||
|
||||
**Stream mode (experimental, not for Cursor):**
|
||||
`variant_mount_failed` means the browser could not render what you published (`variant`, module `url`, `error`). The user sees a persistent error card, not variants. Fix the variant files, then `--reply EVENT_ID done --file <manifest or source path>`; the browser retries on its own.
|
||||
|
||||
```
|
||||
node {{scripts_path}}/live-poll.mjs --stream # stays running; one JSON line per event
|
||||
Handle event; run --reply in a separate command
|
||||
Repeat until "exit" line → Cleanup
|
||||
**Stream mode** (`--stream`, experimental, never on Cursor): one long-lived process, one JSON line per event, `--reply` from a separate command. Only for harnesses that read incremental stdout reliably.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live.mjs
|
||||
```
|
||||
|
||||
Stream keeps one process alive and waits for `--reply` ack before polling again. Useful only when the harness reads incremental stdout reliably and quickly. **Cursor is not one of those:** background pattern notify on a long-running shell was ~5s to pick up events vs sub-second for one-shot exit notify. Default to one-shot everywhere unless you have measured otherwise.
|
||||
Output JSON: `{ ok, serverPort, serverToken, pageFiles, roots, hasProduct, product, productPath, hasDesign, design, designPath, hasSurfaceBrief, surfaceBrief }`. `roots` is the resolved root manifest; `projectRoot` mirrors `roots.appRoot`. The surface brief rides along; do not shell out to `surface-brief.mjs` separately. Precedence for 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 (Step 4 Phase A). Identity preservation is the default; departure requires the user's explicit redesign intent.
|
||||
|
||||
`serverPort`/`serverToken` belong to the small helper HTTP server (`/live.js`, SSE, `/poll`), not your dev server; the page URL is whatever origin serves a `pageFiles` entry.
|
||||
|
||||
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project needs one-time configuration: read [live-setup.md](live-setup.md) and follow it. If the output carries a non-null `configDrift`, tell the user once which HTML files are uncovered and suggest adding them or switching `files` to a glob; never auto-edit the config.
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
The append-only journal under `.impeccable/live/sessions/` is canonical durable state (not project source). When the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-status.mjs
|
||||
node {{scripts_path}}/live-resume.mjs --id SESSION_ID
|
||||
node {{scripts_path}}/live-complete.mjs --id SESSION_ID
|
||||
node {{scripts_path}}/live-status.mjs # helper state, active sessions, queued events; works with the helper down
|
||||
node {{scripts_path}}/live-resume.mjs --id SESSION_ID # active snapshot, pending event, next safe action
|
||||
node {{scripts_path}}/live-complete.mjs --id SESSION_ID # canonical manual final acknowledgement after verified cleanup
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
Server restart rule: start `live-server.mjs` again, then poll; startup requeues unacknowledged events, so never ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
**Replace mode** (default): `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
|
||||
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
|
||||
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`; requires a non-empty `freeformPrompt` **or** annotations. `placeholder` is a soft size hint.
|
||||
|
||||
Speed matters; the user is watching the selected element. Reuse server preflight metadata when available, minimize discovery calls, and follow the harness-specific delivery policy above.
|
||||
Speed matters; the user is watching the selected element. Reuse preflight metadata, minimize discovery calls.
|
||||
|
||||
### Insert mode branch
|
||||
|
||||
When `event.mode === "insert"`:
|
||||
|
||||
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
|
||||
2. If `event.scaffold` is present, use it as the insert-helper result and do **not** run the helper again. Otherwise run the insert helper instead of wrap:
|
||||
1. Read the screenshot if present (annotations only).
|
||||
2. If `event.scaffold` is present, use it and do **not** run the helper again. Otherwise:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
|
||||
--element-id "ANCHOR_ID" --classes "class1,class2" --tag "section" --text "ANCHOR_TEXT"
|
||||
```
|
||||
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
|
||||
`--position` ← `event.insert.position`; anchor flags map exactly like wrap's. The scaffold has **no** `data-impeccable-variant="original"`; variants are net-new HTML+CSS at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock` and `replaceEndLine < replaceStartLine` (an insertion): splice variants into `wrapperBlock` at the marker and insert at `replaceStartLine` in ONE edit, exactly as the wrap section describes. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup. Svelte targets follow the same component flow as wrap below (`mode: "insert"` in the manifest): each variant is a real single-root component under `componentDir` with no `data-impeccable-*` attributes; never edit the route during generation; accept splices the chosen markup into `sourceFile` mechanically. For non-Svelte targets, accept/discard removes the wrapper; the anchor is untouched.
|
||||
|
||||
### Replace mode (default)
|
||||
|
||||
### 1. Read the screenshot (if present)
|
||||
|
||||
`event.screenshotPath` is **only sent when the user placed at least one comment or stroke before Go.** When present, it's an absolute path to a PNG of the element as rendered with the annotations baked in. **Read it before planning**: annotations encode user intent not recoverable from `element.outerHTML` alone.
|
||||
`event.screenshotPath` is sent **only when the user annotated before Go**; it is a PNG of the element with annotations baked in. Read it before planning. When absent, do not ask for one or screenshot the page yourself: without annotations a screenshot anchors you on the existing design and fights the three-distinct-directions brief; work from `element.outerHTML`, the computed styles, and the prompt.
|
||||
|
||||
When `screenshotPath` is absent, don't ask for one and don't go looking for the current rendering. The omission is deliberate: without annotations, a screenshot would anchor the model on the existing design and fight the three-distinct-directions brief. Work from `element.outerHTML`, the computed styles in `event.element`, and the freeform prompt if present.
|
||||
|
||||
`event.comments` and `event.strokes` carry structured metadata alongside the visual. Treat the screenshot as primary; use the structured data for specifics worth quoting (e.g. the exact text of a comment).
|
||||
|
||||
Reading annotations precisely:
|
||||
|
||||
- **Comment position carries meaning.** Its `{x, y}` is element-local CSS px (same coord space as `element.boundingRect`). Find the child under that point and apply the comment text LOCALLY to that sub-element. A comment near the title is about the title, not a global description.
|
||||
- **Comments and strokes are independent annotations** unless clearly paired by overlap or tight proximity. Don't let the visual weight of a prominent stroke override the precise location of a textually-specific comment elsewhere.
|
||||
- **Strokes are gestures; read them by shape.** Closed loop = "this thing" (emphasis / focus); arrow = direction (move / point to); cross or slash = delete; free scribble = emphasis or delete depending on context. A loop around region X means "pay attention to X," not "only change pixels inside X."
|
||||
- **When a stroke's intent is ambiguous** (circle or arrow? emphasis or move?), state your reading in one sentence of rationale rather than silently guessing. If the uncertainty materially changes the brief, ask one short clarifying question before generating.
|
||||
Annotation semantics: a comment's `{x, y}` is element-local and binds the text to the child under that point (a comment near the title is about the title). Comments and strokes are independent unless clearly paired. Strokes read by shape: closed loop = "this thing" (emphasis, not a clipping region); arrow = direction or movement; cross/slash = delete; scribble = emphasis or delete by context. If a stroke's intent is genuinely ambiguous and it changes the brief, ask one short question before generating; otherwise state your reading in one sentence.
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the helper already found the source and computed the wrapper; treat it as the successful output and skip the command. `event.scaffoldAttempted` with `scaffoldError` means preflight could not finish; use the command below.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper; it hands you `scaffold.wrapperBlock` plus the picked element's source range (`replaceStartLine`, `replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands and strands the browser at 0/N. (`replaceEndLine < replaceStartLine` means insert mode: insert, remove nothing.) The `svelte-component` path never sets `sourceWritten`.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
```
|
||||
|
||||
Flag mapping. Keep them separate, don't collapse into `--query`:
|
||||
Flag mapping (keep separate, never collapse into `--query`): `--element-id` ← `event.element.id`; `--classes` ← classes joined with commas; `--tag` ← tagName; `--text` ← first ~80 chars of textContent, **every call**: it disambiguates repeated sibling components, without it wrap lands on the first match. If `event.pageUrl` implies the file, pass `--file PATH`. If `--text` still matches several candidates, wrap exits `{ error: "element_ambiguous", candidates, fallback: "agent-driven" }`: pick the right range from page context and write the wrapper manually per the fallback flow.
|
||||
|
||||
- `--element-id` ← `event.element.id`
|
||||
- `--classes` ← `event.element.classes` joined with commas
|
||||
- `--tag` ← `event.element.tagName`
|
||||
- `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of `<Card>`s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element.
|
||||
Success output: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }` (plus the `sourceWritten: false` fields above on source-preview targets). Run directly with no preflight scaffold, it writes the wrapper itself and you splice variants at `insertLine`. `styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess: `scoped` means `@scope ([data-impeccable-variant="N"])` rules; `astro-global-prefixed` means explicit `[data-impeccable-variant="N"]` prefixes with the exact returned `styleTag`. Use `cssAuthoring` as the source of truth for the current file (styleTag, selector strategy, requirements, forbidden patterns); apply no framework-specific exception unless it says to.
|
||||
|
||||
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups.
|
||||
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` holding the variant components, and `sourceFile` the real route. The scaffold is AST-based: control-flow blocks (`{#each}`, `{#if}`) survive intact and a free each-collection crosses the contract as ONE structured prop (kind `collection`). The payload includes `componentStubMarkup` (the prop-substituted markup already written into every stub), so do not read the manifest or stubs back. EDIT `v1.svelte`, `v2.svelte`, ... in place; never delete and recreate them; keep the stub's control flow and `propContract` prop names; never flatten a loop into literal items. The stub `<style>` arrives seeded with the source rules that currently style the selection; restyle or delete them freely. On accept, any seeded rule your variant does not re-declare is REMOVED from the source (the preview never applied it, so the user approved a design without it). Use semantic class selectors, no `@scope`, no `data-impeccable-*`. Reply with `--file` set to the manifest path; the browser mounts the compiled components so Svelte HMR does not reset page state. Accept merges the chosen component back mechanically (markup restored to route expressions, CSS reconciled, params baked, indentation preserved); you have no post-accept cleanup on this path. When the selection contains constructs a detached preview cannot support (component tags, `bind:`/`use:`, await blocks, inline scripts, spread attributes), wrap returns the normal source-preview wrapper with `previewFallback: { from: "svelte-component", reason }`; just follow the returned shape.
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
**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:
|
||||
**Params on component-preview paths go in a sidecar, never as an attribute** (Svelte parses `{` in attribute values as an expression). Declare them in `componentDir/params.json` keyed by variant number, using the schema from section 7:
|
||||
|
||||
```json
|
||||
{
|
||||
"1": [
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
|
||||
]}
|
||||
],
|
||||
"2": [
|
||||
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
|
||||
]
|
||||
}
|
||||
{ "1": [ {"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"} ]} ] }
|
||||
```
|
||||
|
||||
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
|
||||
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`, wrapped in `:global(...)` so runtime knob values on the mounted root reach your rules.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
- `{ error: "file_is_generated", file, hint }`: user-supplied `--file` points at a generated file.
|
||||
- `{ error: "element_not_in_source", generatedMatch, hint }`: element exists only in a generated file (the next build would wipe any edits).
|
||||
- `{ error: "element_not_found", hint }`: element isn't in any project file; likely runtime-injected (JS component, dynamic render from data).
|
||||
|
||||
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
|
||||
**Fallback errors.** Wrap refuses to write into non-source files (generated, untracked): accepting into one is silent data loss. Three shapes, all with `fallback: "agent-driven"` (see **Handle fallback**): `file_is_generated` (your `--file` points at a generated file), `element_not_in_source` with `generatedMatch` (element only exists generated), `element_not_found` (likely runtime-injected).
|
||||
|
||||
### 3. Load the action's reference
|
||||
|
||||
If `event.action` is `impeccable` (the default freeform action), work from SKILL.md's design rules plus [craft-floor.md](craft-floor.md), and decide the visitor mode from the selected surface. Do not load a sub-command reference. **Freeform is not a pass to skip parameters:** you still follow the composition budget and the freeform bias in **§7 Parameters** below. Sub-command files list MUST-have signature knobs; freeform has no such file, so sizing knobs from surface weight and primary axes is entirely on you.
|
||||
|
||||
Any other `event.action` (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): Read `reference/<action>.md` before planning. Each sub-command encodes a specific discipline; skipping its reference produces generic output. Those files may require specific params; layer them on top of the §7 budget, not instead of it.
|
||||
`event.action` is `impeccable` (freeform): work from SKILL.md's design rules plus [craft-floor.md](craft-floor.md); decide the visitor mode from the surface; do not load a sub-command reference. Freeform is not a pass to skip parameters: follow the budget and freeform bias in section 7. Any other action (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): read `reference/<action>.md` before planning; its MUST params layer on top of the section 7 budget.
|
||||
|
||||
### 4. Plan three variants: identity first, then mode, then axes
|
||||
|
||||
The wrong frame for live mode is "show three different design directions." Live runs on an existing surface; the brand has already been chosen. The job is variation **within identity**, not selection between identities. Failure mode: three editorial-typographic variants on a brief that wasn't editorial. Bigger failure mode: three off-brand variants the user can't accept because they don't look like their product.
|
||||
|
||||
Four phases. Do them in order.
|
||||
Live runs on an existing surface; the brand is already chosen. The job is variation **within identity**, not selection between identities. The worst failure is three off-brand variants the user cannot accept. Four phases, in order.
|
||||
|
||||
#### Phase A: Extract the identity (non-skippable)
|
||||
|
||||
The existing surface has an identity already. Read it before planning anything. Sources, in priority order:
|
||||
|
||||
1. **DESIGN.md** if loaded: read the visual system fields (palette, type pairing, motion, components). This is the authoritative answer.
|
||||
2. **CSS custom properties** in the page's stylesheets (`:root { --color-...; --font-...; ... }`): these are de-facto tokens.
|
||||
3. **Computed styles** on the picked element and its parent: colors, fonts, spacing scales, corner radii.
|
||||
4. **Sibling components on the page**: what visual rhetoric do existing components use? (Asymmetric or centered? Dense or airy? Bold or quiet?)
|
||||
|
||||
Write down what you see in **one sentence**. The sentence describes the surface that's actually on screen; it is not aspirational, not opinionated, not edited toward what the brand "should" be. Capture, in roughly this order:
|
||||
|
||||
- The dominant surface color and accent color, by hex or token name (use the actual values, not categories like "warm" or "neutral").
|
||||
- The type pairing: the actual font names loaded, primary first.
|
||||
- The layout topology: how the dominant elements are arranged (stacked / side-by-side / grid / asymmetric / overlay).
|
||||
- The surface treatment: corners, borders, shadows, density of decoration.
|
||||
- The voice tone you read off the copy itself, not off the aesthetic feel.
|
||||
|
||||
Be specific. "Modern" is not a color, "elegant" is not a type pairing, "clean" is not a layout. If you can't extract a real value for an axis, skip it rather than fabricate. The point is to record what is, not to describe what you wish it were.
|
||||
|
||||
Do not name an aesthetic family in this sentence; that is a conclusion, not observed identity data. Letting conclusions into Phase A collapses the identity lock into a self-fulfilling prophecy.
|
||||
|
||||
This sentence is the **identity lock**. Every variant must be readable as the same brand if rendered side by side. Skipping this phase is the primary cause of off-brand variants. Absence of DESIGN.md is never an excuse; extract from CSS and computed styles instead.
|
||||
Sources in priority order: DESIGN.md's visual system fields; CSS custom properties (de-facto tokens); computed styles on the picked element and parent; sibling components' visual rhetoric. Write ONE sentence recording what is actually on screen: dominant surface and accent color (real values, not "warm"), the loaded font pairing, layout topology (stacked / side-by-side / grid / asymmetric / overlay), surface treatment (corners, borders, shadows, decoration density), and the voice tone read off the copy. Be specific; skip an axis rather than fabricate; do not name an aesthetic family (a conclusion, not data). This sentence is the **identity lock**: every variant must read as the same brand side by side. Absence of DESIGN.md is never an excuse.
|
||||
|
||||
#### Phase B: Pick mode (default vs departure)
|
||||
|
||||
**Default mode**: the existing identity is preserved. Variants vary expression axes within it. *This is the right mode for ~90% of live sessions.* The user picked an element on a real product they're shipping; they expect variants of *their* hero, not three different brands' heroes.
|
||||
|
||||
**Departure mode**: the existing identity is rejected. Variants propose alternatives consistent with durable product and brand truth. Trigger only when the user explicitly asks for departure in the current request or freeform prompt ("redesign this", "rebuild this from scratch", "what if it weren't editorial at all", "show me something completely different"). A stale page critique or an old task note is not replacement authorization.
|
||||
|
||||
If you're unsure, you're in default mode. The cost of being wrong about default is "three on-brand variants with similar feel": recoverable, the user picks none. The cost of being wrong about departure is "three off-brand variants": unrecoverable, the user is annoyed.
|
||||
**Default** preserves the identity and varies expression within it; right for ~90% of sessions. **Departure** rejects the identity; trigger ONLY on the user's explicit ask in the current request or prompt ("redesign this", "rebuild from scratch", "something completely different"); a stale critique or old note is not authorization. Unsure means default: wrong-default costs "three on-brand variants with similar feel" (recoverable), wrong-departure costs three off-brand variants (unrecoverable).
|
||||
|
||||
#### Phase C: Plan three variants
|
||||
|
||||
**Default mode.** Each variant commits to a different **primary axis** of difference, while preserving the identity sentence. The six axes:
|
||||
**Default mode.** Each variant commits to a different **primary axis**, preserving the identity sentence. The six axes: 1 **Hierarchy** (which element commands the eye), 2 **Layout topology** (stacked / side-by-side / grid / asymmetric / overlay), 3 **Typographic system** (pairing logic, scale ratio, case/weight, *within the available faces*), 4 **Color strategy** (which existing palette role carries the surface: Restrained / Committed / Full palette / Drenched; existing tokens only), 5 **Density** (minimal / comfortable / dense), 6 **Structural decomposition** (merge, split, progressive disclosure). Three variants, three DIFFERENT axes: the same brand at three angles. New fonts, new hues, or new aesthetic-family signals belong to departure mode only.
|
||||
|
||||
1. **Hierarchy**: which element commands the eye?
|
||||
2. **Layout topology**: stacked / side-by-side / grid / asymmetric / overlay
|
||||
3. **Typographic system**: pairing logic, scale ratio, case/weight strategy *within the available faces*
|
||||
4. **Color strategy**: which existing palette role carries the surface (Restrained / Committed / Full palette / Drenched). Use the brand's existing palette tokens, not new colors.
|
||||
5. **Density**: minimal / comfortable / dense
|
||||
6. **Structural decomposition**: merge, split, progressive disclosure
|
||||
**Departure mode.** Each variant anchors to a different aesthetic direction derived from the brand, never a fixed catalog: read PRODUCT.md's Brand Personality words; derive physical, spatial, or material experiences that embody them; from those, derive three directions genuinely different from each other AND from the current surface; reject reflex choices whose rationale would fit a neighboring product. Each direction must be one concrete sentence naming a real-world referent ("a museum exhibition label system", not "clean and minimal").
|
||||
|
||||
Three variants → three DIFFERENT axes. The trio reads as *the same brand at three angles*. Do not introduce new fonts, new palette hues, or new aesthetic-family signals; those belong to departure mode.
|
||||
|
||||
**While planning each variant, also name its 2–3 parameter knobs** (per the §7 budget table). Parameters are part of the design, not a decoration added afterward. If the variant explores density, expose a density knob. If it explores color commitment, expose a color-amount range. Deciding "what's tunable" during planning produces better knobs than retrofitting them onto finished HTML.
|
||||
|
||||
**Departure mode.** Each variant anchors to a different **aesthetic direction**, derived from PRODUCT.md's audience world and voice plus the current DESIGN.md. Do not pick from a fixed catalog; derive directions from this product.
|
||||
|
||||
Instead, work from the brand:
|
||||
|
||||
1. Read PRODUCT.md's Brand Personality words. Derive physical, spatial, or material experiences that embody them without starting from a design style.
|
||||
2. From those physical experiences, derive three visual directions that are genuinely different from each other AND from the current surface you're departing.
|
||||
3. Reject any direction chosen by reflex rather than derived from the brand. Start over from the personality words when the rationale could fit a neighboring product.
|
||||
4. Each direction must be expressible in one concrete sentence that names a real-world referent ("a museum exhibition label system for a contemporary art gallery" not "clean and minimal"). If your sentence contains only adjectives, it's not concrete enough.
|
||||
5. **While planning each direction, also name its 2–3 parameter knobs** (per the §7 budget table). The same principle as default mode: decide "what's tunable" during planning, not after writing the HTML. A departure-mode hero with 0 parameters is not "bold creative vision," it's a missed opportunity for the user to fine-tune the direction they pick.
|
||||
**In both modes, name each variant's 2 or 3 parameter knobs while planning** (section 7 budget). Parameters are part of the design; deciding "what's tunable" during planning beats retrofitting.
|
||||
|
||||
#### Phase D: Squint test
|
||||
|
||||
**Default mode squint.** Read each variant's identity sentence and compare to the locked identity from Phase A. If any variant has drifted to a different palette, type voice, or visual rhetoric, it has crossed into departure mode by accident; rework. Then check that each variant commits to a different primary axis. Three "tighter density" variants is failure.
|
||||
**Default:** compare each variant against the Phase A lock; palette, type voice, or rhetoric drift means it crossed into departure by accident: rework. Then confirm three different primary axes; three "tighter density" variants is failure. **Departure:** two passes, family before sentence. Family pass (non-negotiable): label each variant with a concrete family of your own choosing; shared or interchangeable labels mean rework. Sentence pass: three one-line descriptions side by side; two that rhyme mean rework. When the primary axis is color or theme, the trio must not share theme + dominant hue: three color worlds, not three shades.
|
||||
|
||||
**Departure mode squint.** Two passes, family before sentence:
|
||||
**Action-specific invocations** must vary along the action's dimension:
|
||||
|
||||
1. **Family pass.** Give each variant a concrete family label of your own choosing. If two variants share a label, or a label fits another variant equally well, rework. Do not use a fixed vocabulary. *This pass is non-negotiable in departure mode and catches monoculture the sentence pass misses.*
|
||||
2. **Sentence pass.** Write three one-sentence descriptions side by side. If two of them rhyme ("both feature big type" / "both are stacks of sections" / "both center the CTA"), rework the offender.
|
||||
|
||||
**When the primary axis is color or theme, forbid the trio from sharing theme + dominant hue.** Two dark-plus-one-dark is not distinct. Aim for three color worlds, not three shades of the same.
|
||||
|
||||
**For action-specific invocations**, each variant must vary along the dimension the action names:
|
||||
|
||||
- `bolder`: amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants.
|
||||
- `bolder`: amplify a different dimension per variant (scale / saturation / structural change).
|
||||
- `quieter`: pull back a different dimension (color / ornament / spacing).
|
||||
- `distill`: remove a different class of excess (visual noise / redundant content / nested structure).
|
||||
- `polish`: target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning).
|
||||
- `typeset`: different type pairing AND different scale ratio each. Not three riffs on one pairing.
|
||||
- `colorize`: different hue family each (not shades of one hue). Vary chroma and contrast strategy.
|
||||
- `layout`: different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks.
|
||||
- `adapt`: different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts.
|
||||
- `animate`: different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades.
|
||||
- `delight`: different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction).
|
||||
- `overdrive`: different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step; live mode is non-interactive.
|
||||
- `polish`: a different refinement axis (rhythm / hierarchy / micro-details).
|
||||
- `typeset`: different pairing AND different scale ratio each.
|
||||
- `colorize`: different hue family each; vary chroma and contrast strategy.
|
||||
- `layout`: different structural arrangement, not spacing tweaks.
|
||||
- `adapt`: different target context per variant (mobile-first / tablet / desktop / print or low-data).
|
||||
- `animate`: different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax).
|
||||
- `delight`: different flavor of personality (micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic / easter egg).
|
||||
- `overdrive`: different convention broken (scale / structure / motion / input model / state transitions); skip its "propose and ask" step, live is non-interactive.
|
||||
|
||||
### 5. Apply the freeform prompt (if present)
|
||||
|
||||
`event.freeformPrompt` is the user's ceiling on direction (all variants must honor it), but still explore meaningfully different *interpretations*. The interpretations stay within whichever mode you picked in Phase B.
|
||||
|
||||
In **default mode**, the prompt narrows the axes you choose, not the identity. *"Make it feel more confident"* → variant 1 amplifies hierarchy (one element commands the eye), variant 2 commits the existing accent color (Committed strategy on the brand's hue), variant 3 tightens density and removes decorative slack. Three different axes, same brand.
|
||||
|
||||
In **departure mode**, the prompt narrows the lanes you draw from, not the families. *"Make it feel like a newspaper front page"* would itself be a departure-mode prompt; honor it but pick three meaningfully different newspaper-adjacent lanes (broadsheet vs. tabloid vs. trade journal), and run the family pass to confirm they don't collapse into one.
|
||||
|
||||
When the prompt conflicts with a confirmed binding brand commitment or DESIGN.md invariant, preserve the invariant unless the user explicitly revokes or replaces it. Task-local strategy from the matching surface brief may change when the user changes that surface's goal.
|
||||
`event.freeformPrompt` is the user's ceiling on direction: all variants honor it while exploring different interpretations within the Phase B mode. Default mode: the prompt narrows the axes, not the identity ("more confident" → one variant amplifies hierarchy, one commits the accent color, one tightens density). Departure mode: the prompt narrows the lanes, not the families ("newspaper front page" → broadsheet vs tabloid vs trade journal, then run the family pass). When the prompt conflicts with a binding brand commitment or DESIGN.md invariant, preserve the invariant unless the user explicitly revokes it.
|
||||
|
||||
### 6. Deliver variants
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and keeps each delivered state internally complete (no FOUC).
|
||||
|
||||
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
Complete HTML replacement of the original element per variant, not a CSS-only patch. Colocate preview CSS as a `<style>` tag inside the wrapper. **Atomic default:** CSS + all variants + parameter manifests in one edit at `insertLine`.
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
@@ -319,92 +191,55 @@ Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporar
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
</div>
|
||||
<div data-impeccable-variant="2" style="display: none">
|
||||
<!-- variant 2: full element replacement -->
|
||||
<!-- variant 2 -->
|
||||
</div>
|
||||
<div data-impeccable-variant="3" style="display: none">
|
||||
<!-- variant 3: full element replacement -->
|
||||
<!-- variant 3 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child.
|
||||
Replace the style opening tag with `cssAuthoring.styleTag` when the tool returns a different one. **Each variant div contains exactly one top-level element**, same tag as the original; loose siblings break outline tracking and accept. First variant visible, all others `display: none`. The browser's MutationObserver accepts atomic or progressive arrival; accepting an arrived variant fences the worker, so later publications are rejected.
|
||||
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator: the `@scope` boundary is the variant wrapper div, not your element, so a bare `:scope { ... }` styles a `display: contents` shell. Always step in (`:scope > .card`, `:scope .hero-title`). The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template.
|
||||
|
||||
The browser's MutationObserver accepts either delivery shape. On the transactional progressive path it shows arrived variants and pending dots immediately; Accept and Discard are available as soon as one variant exists. Accepting an arrived variant fences the worker before the browser releases the picker, so later publications are rejected.
|
||||
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
**JSX / TSX targets:** wrap `<style>` content in a template literal (CSS braces would parse as JSX), use `className=` / `style={{…}}`, keep `data-impeccable-*` attributes as plain strings:
|
||||
|
||||
```tsx
|
||||
<style data-impeccable-css="SESSION_ID">{`
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
`}</style>
|
||||
<div data-impeccable-variant="1">
|
||||
{/* variant 1 */}
|
||||
</div>
|
||||
<div data-impeccable-variant="2" style={{ display: 'none' }}>
|
||||
{/* variant 2 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
The wrap script already gives you a single-rooted JSX wrapper: a `<div data-impeccable-variants="…">` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX.
|
||||
The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX.
|
||||
|
||||
### 7. Parameters (composition-sized, 0–4 per variant)
|
||||
### 7. Parameters (composition-sized, 0-4 per variant)
|
||||
|
||||
Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
|
||||
Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point.
|
||||
|
||||
**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.”
|
||||
Budget scales with the element's VISUAL weight (count visual children, not DOM depth):
|
||||
|
||||
**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters.
|
||||
- **Leaf / tiny** (button, icon, bare heading): **0 params.**
|
||||
- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**.
|
||||
- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple.
|
||||
- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS.
|
||||
|
||||
**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero.
|
||||
**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs.
|
||||
|
||||
**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise.
|
||||
|
||||
- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.**
|
||||
- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.**
|
||||
- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points.
|
||||
- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS.
|
||||
|
||||
**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large.
|
||||
|
||||
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path.
|
||||
**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section):
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"},
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},
|
||||
{"value":"snug","label":"Snug"},
|
||||
{"value":"packed","label":"Packed"}
|
||||
]},
|
||||
{"id":"serif","kind":"toggle","default":false,"label":"Serif display"}
|
||||
]'>
|
||||
...variant content...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Three kinds:**
|
||||
Three kinds: `range` (slider; drives `--p-<id>`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-<id>`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-<id>: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults.
|
||||
|
||||
- `range`: smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
|
||||
- `steps`: segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
|
||||
- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
|
||||
|
||||
**Signature params per action.** For named sub-commands, read that action’s `reference/<action>.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs.
|
||||
|
||||
**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later.
|
||||
|
||||
**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment:
|
||||
|
||||
```html
|
||||
<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7,"density":"packed"} -->
|
||||
```
|
||||
|
||||
The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default.
|
||||
**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: `<!-- impeccable-param-values SESSION_ID: {"color-amount":0.7} -->`. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default.
|
||||
|
||||
### 8. Signal done
|
||||
|
||||
@@ -412,127 +247,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v
|
||||
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
|
||||
```
|
||||
|
||||
`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR.
|
||||
|
||||
Then run `live-poll.mjs` again immediately.
|
||||
`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately.
|
||||
|
||||
### Aborting an in-flight session
|
||||
|
||||
If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID error "Short reason"
|
||||
```
|
||||
|
||||
Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered.
|
||||
If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node {{scripts_path}}/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated.
|
||||
|
||||
## Handle fallback
|
||||
|
||||
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
|
||||
When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it.
|
||||
|
||||
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
|
||||
|
||||
### Step 1: Identify where the element actually lives
|
||||
|
||||
Use the error payload:
|
||||
|
||||
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
|
||||
- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
|
||||
- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
|
||||
|
||||
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
|
||||
|
||||
### Step 2: Show three variants in the DOM for preview
|
||||
|
||||
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
|
||||
|
||||
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
|
||||
2. Insert your three variant divs inside it, same shape as the deterministic path.
|
||||
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
|
||||
|
||||
This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept.
|
||||
|
||||
### Step 3: On accept, write to true source
|
||||
|
||||
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
|
||||
|
||||
- Structural change → edit the template / component source.
|
||||
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
|
||||
- Dynamic from data → update the data source or the render logic.
|
||||
|
||||
Then remove the temporary wrapper from the served file if it's still there.
|
||||
|
||||
### Step 4: On discard, clean up the served file
|
||||
|
||||
Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
1. **Find where the element really lives** from the error payload: `element_not_in_source` + `generatedMatch` means the served HTML is generated, so find the generator's template or partial; `element_not_found` means runtime-injected, so find the rendering component or data source; `file_is_generated` resolves the same way. A purely visual change may belong in a shared stylesheet rather than a template.
|
||||
2. **Preview in the served file**: manually write the same wrapper scaffold `live-wrap.mjs` produces (`<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`) into the file the browser actually loaded, insert your variant divs, `--reply EVENT_ID done --file <served file>`. This edit is temporary; a regen wiping it is fine.
|
||||
3. **On accept, write to true source** (accept refuses generated files, so `_acceptResult.handled` is usually `false` here): structural change → template/component source; visual-only → the right stylesheet; content rendered from data → the data source or render logic. Then remove the temporary wrapper from the served file.
|
||||
4. **On discard**, just remove the temporary wrapper.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` deterministically and acknowledged delivery; the browser DOM is already updated.
|
||||
|
||||
- The accept event includes `pageUrl`; the poll script must forward it to `live-accept.mjs --page-url PAGE_URL` so accept-time cleanup only scrubs staged copy edits for the current page.
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: post-accept cleanup is required, but it must not stall Codex's control lane. See "Required after accept (carbonize)" below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and stderr banner all point at this required follow-up; none are decorative.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false, mode: "error"`: the operation genuinely failed. **Do not hand-edit the file**; the source was not touched and editing it yourself would either double-apply or race whoever holds it.
|
||||
- `error: "source_locked"`: a generation publish holds the file. Run the same `live-accept.mjs` command again; it is idempotent and will succeed once the publisher releases. Do not poll past it.
|
||||
- `error: "accept_receipt_conflict"`: this session already resolved as `priorOperation` (on `priorVariantId` for an accept), so the request contradicts durable truth. Do not edit. Run `live-status.mjs` and tell the user what the session actually resolved to.
|
||||
- anything else: report the error briefly and run `live-status.mjs` before continuing.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, finish cleanup manually if needed, then `live-complete.mjs --id EVENT_ID`.
|
||||
- `handled: true, carbonize: false`: nothing to do; poll again.
|
||||
- `handled: true, carbonize: true`: required cleanup below; `_acceptResult.todo`, `_completionAck.requiresComplete`, and the stderr banner all point at it.
|
||||
- `handled: false, mode: "fallback"`: the session lived in a generated file; you already wrote true source in fallback Step 3; clean the temporary wrapper and poll.
|
||||
- `handled: false, mode: "error"`: **do not hand-edit the file.** `source_locked`: rerun the same `live-accept.mjs` command (idempotent) until the publisher releases. `accept_receipt_conflict`: the session already resolved as `priorOperation`; run `live-status.mjs` and tell the user. Anything else: report briefly, run `live-status.mjs` first.
|
||||
- `handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
### Required after accept (carbonize)
|
||||
|
||||
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
|
||||
`carbonize: true` means the accepted variant is stitched into source with helper markers and inline CSS (so the browser renders with no gap). That stitch-in is temporary; rewrite it into permanent form before anything else, or dead `@scope` rules, wrapper divs, and marker comments accumulate across sessions. Five steps, synchronously, before the next poll:
|
||||
|
||||
Do these five steps synchronously before the next poll. The source lock, generation epoch, and expected-source hash remain the final safety gates against a generator finishing concurrently with Accept.
|
||||
1. **Locate the carbonize block** in `_acceptResult.file`: bracketed by `<!-- impeccable-carbonize-start/end SESSION_ID -->` with a `<style data-impeccable-css>` element; read the `<!-- impeccable-param-values -->` comment first when present, it drives steps 3 and 4.
|
||||
2. **Move the CSS rules** into the project's real stylesheet (whichever already owns styling for the surrounding element).
|
||||
3. **Bake param values while rewriting selectors**: retarget `@scope ([data-impeccable-variant="N"])` to real semantic classes; keep only the `:scope[data-p-<id>="VALUE"]` branch matching the chosen value; substitute `var(--p-<id>)` literals or update the var's default.
|
||||
4. **Unwrap the accepted content**: delete the inner variant div (and on JSX the outer `data-impeccable-carbonize` div); drop `data-impeccable-params` and all `data-p-*` attributes.
|
||||
5. **Delete** the inline `<style>` block, the param-values comment, both carbonize markers, and any `@scope` rules for non-accepted variants.
|
||||
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
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.
|
||||
Then run `live-complete.mjs --id SESSION_ID` and verify `phase: "completed"` before polling again. The command is a gate, not a formality: it refuses with `error: "source_dirty"` plus findings while any live-mode leftover remains; fix and rerun (`--force` only for false positives).
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original and acknowledged `discarded`. Nothing to do unless `_completionAck.ok !== true`; then `live-complete.mjs --id EVENT_ID --discarded` and poll again.
|
||||
|
||||
## Handle `steer`
|
||||
|
||||
Event: `{id, message, pageUrl}`. The user typed or spoke into the global bar **Steer** control: page-level direction without picking an element or launching variant generation.
|
||||
|
||||
The mic button uses the browser **Web Speech API** (MVP): click to start, speak, stop automatically when the utterance ends, then the transcript submits as a steer event. Click again while listening to cancel without submitting.
|
||||
|
||||
This is lighter than `generate`: no screenshot, no element context, no variant cycling. Read `message` and inspect the live page or project files as needed, then either make edits or answer in prose.
|
||||
|
||||
When finished:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID steer_done ["Optional short note for a browser toast"]
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID error "Short reason"
|
||||
```
|
||||
|
||||
Then poll again immediately. Do not send a separate "picked up" reply. The Steer bar stays locked until `steer_done` or `error` arrives over SSE.
|
||||
Event: `{id, message, pageUrl}`: page-level direction from the global bar's Steer control (typed or spoken), no element context, no variant cycling. Read `message`, inspect the page or files as needed, make edits or answer in prose. Reply `node {{scripts_path}}/live-poll.mjs --reply EVENT_ID steer_done ["Optional short toast"]`, or on failure `--reply EVENT_ID error "Short reason"`, then poll immediately. No separate pickup reply; the Steer bar unlocks on `steer_done` or `error`.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut; it signals the user is likely about to Go on a page you haven't read yet.
|
||||
|
||||
Resolve `pageUrl` to the underlying file:
|
||||
|
||||
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
|
||||
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo` → `public/foo/index.html`; SPAs may map all routes to a single entry).
|
||||
|
||||
Read the file into context, then poll again. No `--reply`: this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
|
||||
|
||||
Dedupe is the browser's job (one prefetch per unique pathname per session); trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
|
||||
Event: `{pageUrl}`: fired once per route on first selection; the user is likely about to Go on a page you have not read. Resolve the route to its file (root `/` is usually the boot's `pageFile`; multi-page sites often map `/foo` to `public/foo/index.html`; SPAs map everything to one entry), read it, poll again. No `--reply`. If you cannot resolve it confidently, skip and poll.
|
||||
|
||||
## Handle `manual_edit_apply`
|
||||
|
||||
@@ -548,12 +312,7 @@ After source edits finish, reply exactly once with `node {{scripts_path}}/live-p
|
||||
|
||||
## Exit
|
||||
|
||||
The user can stop live mode by:
|
||||
- Saying "stop live mode" / "exit live" in chat
|
||||
- Closing the browser tab (SSE drops, poll returns `exit` after 8s)
|
||||
- The browser's exit button
|
||||
|
||||
When the poll returns `exit`, proceed to cleanup. If the poll is still running as a background task, kill it first.
|
||||
The user stops live mode by saying so in chat, closing the tab (SSE drops; poll returns `exit` after 8s), or the browser's exit button. On `exit`, kill any still-running background poll, then clean up.
|
||||
|
||||
## Cleanup
|
||||
|
||||
@@ -561,175 +320,8 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node {{scripts_path}}/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
Stops the helper and runs `live-inject.mjs --remove` to strip the injected script (use `stop --keep-inject` to keep it for a quick restart; `.impeccable/live/config.json` persists as project config). Then search for and remove any leftover `impeccable-variants-start` wrappers and `impeccable-carbonize-start` blocks.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
|
||||
## First-time setup
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target; **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
|
||||
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config; let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
Otherwise, run the detection helper:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`**: CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`middleware`** or **`meta-tag`**: rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
|
||||
|
||||
#### Consent prompt template
|
||||
|
||||
Use this phrasing so the experience is consistent across agents:
|
||||
|
||||
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400`: the live picker won't load without an allowance. Here's the change I'd make:
|
||||
>
|
||||
> ```diff
|
||||
> [file: <patchTarget>]
|
||||
> [exact diff, 2–5 lines]
|
||||
> ```
|
||||
>
|
||||
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
|
||||
|
||||
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### append-arrays
|
||||
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper**: edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit**: edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security**: edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
|
||||
|
||||
#### append-string
|
||||
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
|
||||
```
|
||||
|
||||
Then in the CSP value string:
|
||||
- `script-src 'self' 'unsafe-inline'` → `` `script-src 'self' 'unsafe-inline'${__impeccableLiveDev}` ``
|
||||
- `connect-src 'self'` → `` `connect-src 'self'${__impeccableLiveDev}` ``
|
||||
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`**: edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`**: edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
Only when `live.mjs` reports `config_missing` / `config_invalid`, or `configDrift` needs explaining, or the config lacks `cspChecked`: read [live-setup.md](live-setup.md). It owns the config schema, the per-framework `files` table, injection adapters, drift healing, and the CSP detection and consent flow.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+924
-103
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();
|
||||
}
|
||||
|
||||
+149
-414
@@ -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;
|
||||
}
|
||||
|
||||
@@ -180,50 +205,68 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
// Optional server token: appended to the /live.js src so the token-gated
|
||||
// /live.js handler authorizes the browser fetch. `live.mjs` always passes it.
|
||||
// /live.js handler authorizes the browser fetch. `live.mjs` always passes
|
||||
// it; a manual `--port`-only invocation reads the running helper's token
|
||||
// from server.json instead of writing an unauthenticated URL that 401s.
|
||||
const tokenIdx = args.indexOf('--token');
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
let token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
if (!token) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'server.json'), 'utf-8'));
|
||||
// A record for a DIFFERENT port is a stale or foreign helper; its token
|
||||
// would 401 just the same, so only adopt a matching one.
|
||||
if (info?.token && Number(info.port) === port) token = info.token;
|
||||
} catch { /* no running helper recorded; keep legacy tokenless behavior */ }
|
||||
}
|
||||
|
||||
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 +279,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 +326,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 +473,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,8 @@ 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';
|
||||
import { instructionsForEvent } from './live/instructions.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.
|
||||
@@ -27,7 +29,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup', 'variant_mount_failed']);
|
||||
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
@@ -117,8 +119,11 @@ export async function postReply(base, token, reply) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean);
|
||||
throw new Error(parts.join(': '));
|
||||
const failureLines = Array.isArray(body.failures)
|
||||
? body.failures.map((f) => ` ${f.file}${f.line != null ? `:${f.line}` : ''} ${f.message}`).join('\n')
|
||||
: null;
|
||||
const parts = [body.error || res.statusText, body.reason, body.hint, failureLines, body._instructions].filter(Boolean);
|
||||
throw new Error(parts.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +266,13 @@ export function writeCarbonizeBanner(event) {
|
||||
}
|
||||
|
||||
export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -412,5 +424,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 ${snapshot?.pendingEvent?.id || snapshot?.id || 'SESSION_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();
|
||||
}
|
||||
|
||||
+176
-17
@@ -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,53 @@ import {
|
||||
} from './live/manual-apply.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
bumpSvelteComponentPreviewRevision,
|
||||
compileCheckVariants,
|
||||
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 events allowed to mint a NEW session journal. `generate` starts
|
||||
// a variant session at Go; `steer` mints its own request id. Every other
|
||||
// id-carrying event must land on an existing session (see the unknown_session
|
||||
// gate in the /events handler).
|
||||
const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']);
|
||||
// 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
|
||||
@@ -150,7 +182,16 @@ function chatAgentLikelyActive() {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
if (!event) return;
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
const duplicate = event.id && state.pendingEvents.some((entry) => (
|
||||
entry.event?.id === event.id
|
||||
&& entry.event?.type === event.type
|
||||
&& (event.type !== 'variant_mount_failed' || entry.event?.variant === event.variant)
|
||||
));
|
||||
if (duplicate) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
|
||||
flushPendingPolls();
|
||||
}
|
||||
@@ -445,6 +486,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 +664,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) {
|
||||
@@ -690,6 +736,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
appRoot: process.cwd(),
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
@@ -827,8 +874,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);
|
||||
|
||||
@@ -979,6 +1027,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
// Only the events that START a session may create its journal.
|
||||
// Everything else (checkpoints, mount acks, accept/discard) must
|
||||
// reference a session THIS store already knows: appendEvent creates a
|
||||
// journal for any id it is handed, so without this gate a browser
|
||||
// resuming another project's session from per-origin storage (two
|
||||
// apps sharing a localhost port) materializes a ghost session here
|
||||
// that keeps reattaching after every discard.
|
||||
if (msg.id && state.sessionStore
|
||||
&& !SESSION_CREATING_EVENT_TYPES.has(msg.type)
|
||||
&& !state.sessionStore.has(msg.id)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'unknown_session', id: msg.id }));
|
||||
return;
|
||||
}
|
||||
const missedCompletion = detectMissedGenerationCompletion(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
@@ -997,7 +1059,25 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
if (msg.type === 'exit') {
|
||||
cleanupSvelteComponentSessionsBeforeExit();
|
||||
}
|
||||
if (msg.type !== 'checkpoint') {
|
||||
// An ORPHANED discard is the browser reporting that the session's
|
||||
// wrapper no longer exists in source (edited or regenerated away).
|
||||
// There is no cleanup for an agent to perform, and asking one to run
|
||||
// the normal discard flow would just fail against the missing
|
||||
// scaffolding, so the server terminalizes the session itself and the
|
||||
// event stays out of the poll queue.
|
||||
const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true;
|
||||
if (orphanedDiscard && state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true });
|
||||
} catch { /* the discard_requested phase already left the resumable set */ }
|
||||
}
|
||||
// `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' && !orphanedDiscard) {
|
||||
enqueueEvent(msg);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -1099,7 +1179,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 +1220,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 +1352,30 @@ 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).
|
||||
// Broken variants are bounced HERE, before the browser imports anything:
|
||||
// a compile error that reaches the page is a red overlay in the user's
|
||||
// face; bounced at publish it is a private fix with file and line.
|
||||
if (replyFileMeta.previewMode === 'svelte-component'
|
||||
&& msg.id
|
||||
&& (msg.type === 'done' || !msg.type)) {
|
||||
let compileCheck = { ok: true, failures: [] };
|
||||
try { compileCheck = compileCheckVariants(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
if (!compileCheck.ok) {
|
||||
res.writeHead(422, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: 'variant_compile_failed',
|
||||
id: msg.id,
|
||||
failures: compileCheck.failures,
|
||||
_instructions: 'The publish was NOT delivered: the listed variant file(s) do not compile, so the browser never saw them. Fix each failure at the given file and line (the most common cause is a second top-level <style> element; Svelte allows exactly one, so merge all rules into the existing block), then send the same --reply done again.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
|
||||
}
|
||||
if (state.sessionStore && msg.id && !skipJournalReply) {
|
||||
try {
|
||||
const eventType = msg.type === 'steer_done'
|
||||
@@ -1335,6 +1447,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 +1631,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();
|
||||
}
|
||||
|
||||
+50
-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,9 @@ 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,
|
||||
componentStubMarkup: componentSession?.stubMarkup,
|
||||
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 +449,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 +653,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 +908,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();
|
||||
}
|
||||
|
||||
|
||||
+81
-24
@@ -21,10 +21,13 @@ 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 { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
import { bootInstructions } from './live/instructions.mjs';
|
||||
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -60,6 +63,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 +76,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 +108,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 +127,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);
|
||||
}
|
||||
@@ -134,7 +163,28 @@ The agent should then:
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs. The surface brief rides along so the
|
||||
// agent does not spend three more tool calls (and a --help miss) on
|
||||
// surface-brief.mjs before the first poll.
|
||||
let surfaceBrief = null;
|
||||
let surfaceBriefPath = null;
|
||||
try {
|
||||
// Briefs live under .impeccable/surfaces, which in a nested-app repo sits
|
||||
// at the CONTEXT or repo root, not the app root; context.mjs already finds
|
||||
// them there, and live must not report "no brief" for the same project.
|
||||
const briefRoots = [roots.appRoot, roots.contextRoot, roots.repoRoot]
|
||||
.filter(Boolean)
|
||||
.filter((dir, i, arr) => arr.findIndex((other) => path.resolve(other) === path.resolve(dir)) === i);
|
||||
for (const briefRoot of briefRoots) {
|
||||
const resolvedBrief = resolveSurfaceBrief(briefRoot, liveTarget.absoluteTargetPath || null);
|
||||
if (!resolvedBrief?.brief) continue;
|
||||
surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path);
|
||||
surfaceBriefPath = resolvedBrief.brief.path
|
||||
? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path)
|
||||
: null;
|
||||
break;
|
||||
}
|
||||
} catch { /* briefs are optional context */ }
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
@@ -143,22 +193,29 @@ 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),
|
||||
hasSurfaceBrief: !!surfaceBrief,
|
||||
surfaceBrief,
|
||||
surfaceBriefPath,
|
||||
_instructions: bootInstructions({ scriptsPath: __dirname }),
|
||||
}, 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,617 @@
|
||||
/**
|
||||
* 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 {
|
||||
// New base rules go BEFORE the existing top-level media blocks:
|
||||
// appended after them, an equal-specificity base rule wins the
|
||||
// cascade over the stylesheet's earlier responsive overrides and
|
||||
// silently weakens the mobile styles for any still-shared class.
|
||||
const appendedNode = { ...node };
|
||||
const firstAt = existingNodes.findIndex((n) => n.type === 'at' && n.children);
|
||||
if (firstAt === -1) existingNodes.push(appendedNode);
|
||||
else existingNodes.splice(firstAt, 0, appendedNode);
|
||||
index.set(key, appendedNode);
|
||||
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: the runtime sets data-p-<id>="on" when on and removes the
|
||||
// attribute when off. A branch survives baking only if it actually
|
||||
// matched at preview time with the chosen state: the presence form and
|
||||
// the literal "on" form match while on; every other valued form
|
||||
// (["false"], ["0"], ...) never matched and is dead regardless of state.
|
||||
if (expected != null && expected !== 'on') {
|
||||
drop = true;
|
||||
return '';
|
||||
}
|
||||
if (!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 the end
|
||||
// of the <style> open tag. A bare `>` is NOT a boundary: it is the child
|
||||
// combinator, and cutting there truncates a selector list like
|
||||
// `.a > .b, .c` mid-prelude. Only a `>` that closes a `<style ...>` tag
|
||||
// bounds the walk.
|
||||
let preludeStart = start;
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
const ch = source[i];
|
||||
if (ch === '}' || ch === '{' || ch === ';') { preludeStart = i + 1; break; }
|
||||
if (ch === '>') {
|
||||
const styleOpen = source.lastIndexOf('<style', i);
|
||||
if (styleOpen !== -1 && source.indexOf('>', styleOpen) === i) { preludeStart = i + 1; break; }
|
||||
continue; // child combinator inside the prelude
|
||||
}
|
||||
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,60 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Param patterns are anchored to the exact shapes live mode writes
|
||||
// (attribute-with-value / selector forms, var() references), not bare
|
||||
// substrings, so user tokens that merely share the prefix cannot trip the
|
||||
// completion gate.
|
||||
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: /\bdata-p-[A-Za-z0-9_-]+\s*(?:=|\])/, label: 'data-p-*', why: 'preview parameter attribute left on markup' },
|
||||
{ marker: /var\(\s*--p-[A-Za-z0-9_-]+\s*[,)]/, label: '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, label, why } of FORBIDDEN) {
|
||||
const hit = marker instanceof RegExp ? marker.test(line) : line.includes(marker);
|
||||
if (hit) {
|
||||
findings.push({
|
||||
marker: label || String(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 };
|
||||
}
|
||||
@@ -32,10 +32,15 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
// Project identity for browser-side session storage. localStorage is
|
||||
// keyed by ORIGIN, and two projects routinely share a localhost port
|
||||
// across time; saved sessions carry this value so a resume can tell a
|
||||
// foreign project's leftovers from its own.
|
||||
`window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` +
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
|
||||
@@ -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,142 @@
|
||||
/**
|
||||
* Just-in-time agent instructions for live mode.
|
||||
*
|
||||
* The live scripts, not the reference doc, own situational plumbing: every
|
||||
* event printed by live-poll carries an `_instructions` string describing
|
||||
* exactly what to do NEXT, with real ids, paths, and line numbers already
|
||||
* substituted and only the active path's rules included (a svelte-component
|
||||
* session never sees JSX guidance, and vice versa). live.md stays lean: the
|
||||
* session contract, harness policy, and design-quality guidance that is not
|
||||
* situational (identity lock, variation axes, parameter budgets).
|
||||
*
|
||||
* Keep these strings imperative, concrete, and short. They are read by an
|
||||
* agent mid-session; every sentence must earn its tokens. Instructions are
|
||||
* versioned with the scripts, so they cannot drift from behavior the way a
|
||||
* hand-maintained doc can.
|
||||
*/
|
||||
|
||||
const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.';
|
||||
|
||||
function pollCmd(scriptsPath) {
|
||||
return `node ${scriptsPath}/live-poll.mjs`;
|
||||
}
|
||||
|
||||
function replyCmd(scriptsPath, id, rest) {
|
||||
return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`;
|
||||
}
|
||||
|
||||
export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) {
|
||||
if (!event || typeof event !== 'object') return undefined;
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
return generateInstructions(event, scriptsPath);
|
||||
case 'steer':
|
||||
return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`;
|
||||
case 'prefetch':
|
||||
return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`;
|
||||
case 'variant_mount_failed':
|
||||
return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file <manifest or source path>')}; the browser retries on its own. Poll again after the reply.`;
|
||||
case 'accept':
|
||||
return acceptInstructions(event, scriptsPath);
|
||||
case 'discard':
|
||||
return event?._completionAck?.ok === true
|
||||
? 'Original restored and durable completion acknowledged; nothing to do. Poll again.'
|
||||
: `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`;
|
||||
case 'manual_edit_apply':
|
||||
return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`;
|
||||
case 'timeout':
|
||||
return 'No event arrived; poll again immediately.';
|
||||
case 'exit':
|
||||
return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function generateInstructions(event, scriptsPath) {
|
||||
const id = event.id;
|
||||
const scaffold = event.scaffold;
|
||||
const steps = [];
|
||||
|
||||
if (event.screenshotPath) {
|
||||
steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`);
|
||||
} else {
|
||||
steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.');
|
||||
}
|
||||
|
||||
if (event.mode === 'insert') {
|
||||
steps.push(insertScaffoldInstructions(event, scriptsPath));
|
||||
} else if (scaffold?.previewMode === 'svelte-component') {
|
||||
steps.push(svelteComponentInstructions(event, scaffold, scriptsPath));
|
||||
} else if (scaffold && scaffold.sourceWritten === false) {
|
||||
steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath));
|
||||
} else if (scaffold) {
|
||||
steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`);
|
||||
} else {
|
||||
steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "<first ~80 chars of the picked element's textContent>". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`);
|
||||
}
|
||||
|
||||
steps.push(event.action && event.action !== 'impeccable'
|
||||
? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}`
|
||||
: `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`);
|
||||
|
||||
steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file <project-root-relative path you wrote>')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`);
|
||||
|
||||
return steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
|
||||
}
|
||||
|
||||
function svelteComponentInstructions(event, scaffold, scriptsPath) {
|
||||
const dir = scaffold.componentDir;
|
||||
const count = event.count;
|
||||
return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub <style> is seeded with the source rules that style the selection; restyle or delete freely, and know that any seeded rule you do not re-declare is REMOVED from source on accept (the preview never applied it). ALL your CSS goes inside that ONE existing <style> block: Svelte forbids a second top-level style element, and a publish with a non-compiling variant is bounced back to you with file and line. Semantic class selectors only: no @scope, no data-impeccable-* attributes. Params go in ${dir}/params.json keyed by variant number (never an attribute); author knob CSS against var(--p-<id>, default) and :global([data-p-<id>="..."]). Reply with --file ${scaffold.file}. Accept later merges everything into ${scaffold.sourceFile} mechanically; you have no post-accept cleanup.`;
|
||||
}
|
||||
|
||||
function deferredWrapperInstructions(event, scaffold, scriptsPath) {
|
||||
const insertNote = Number(scaffold.replaceEndLine) < Number(scaffold.replaceStartLine)
|
||||
? ` (replaceEndLine < replaceStartLine: this is an INSERTION at line ${scaffold.replaceStartLine}; remove nothing)`
|
||||
: '';
|
||||
return `The wrapper is NOT in source yet. In ONE edit to ${scaffold.file}: splice preview CSS plus all ${event.count} variants into scaffold.wrapperBlock at the "Variants: insert below this line" marker, then replace lines ${scaffold.replaceStartLine}-${scaffold.replaceEndLine}${insertNote} with the result. Two separate writes reload the framework mid-publish and strand the browser at 0/N. Author CSS per the returned cssAuthoring contract; each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none. On JSX/TSX wrap the <style> content in a template literal and use className / style={{...}}.`;
|
||||
}
|
||||
|
||||
function insertScaffoldInstructions(event, scriptsPath) {
|
||||
const scaffold = event.scaffold;
|
||||
const base = `Insert mode: net-new content sized around ${event.placeholder?.width || '?'}x${event.placeholder?.height || '?'} at the chosen anchor; load craft-floor.md before writing net-new markup.`;
|
||||
if (scaffold?.previewMode === 'svelte-component') {
|
||||
return `${base} Write each inserted variant as a single-root Svelte component under ${scaffold.componentDir} (no data-impeccable-* attributes, CSS in each component's <style>). Never edit the route during generation; reply with --file ${scaffold.file}.`;
|
||||
}
|
||||
if (scaffold && scaffold.sourceWritten === false) {
|
||||
return `${base} Splice your variants into scaffold.wrapperBlock at the marker and insert the result at line ${scaffold.replaceStartLine} of ${scaffold.file} in ONE edit.`;
|
||||
}
|
||||
return `${base} If no scaffold payload is present, run node ${scriptsPath}/live-insert.mjs --id ${event.id} --count ${event.count} --position ${event.insert?.position || 'after'} with the anchor flags from event.insert.anchor, then splice variants at the returned insertLine.`;
|
||||
}
|
||||
|
||||
function acceptInstructions(event, scriptsPath) {
|
||||
const result = event._acceptResult || {};
|
||||
const ackOk = event._completionAck?.ok === true;
|
||||
const prefix = ackOk ? '' : `Completion was NOT acknowledged: run node ${scriptsPath}/live-status.mjs, finish any cleanup, then node ${scriptsPath}/live-complete.mjs --id ${event.id}. `;
|
||||
|
||||
if (result.handled === true && result.carbonize === true) {
|
||||
return `${prefix}Carbonize cleanup is REQUIRED now, before the next poll, in ${result.file}: (1) locate the impeccable-carbonize-start/end block and read the impeccable-param-values comment; (2) move the CSS rules into the stylesheet that owns this area; (3) bake params while rewriting selectors (@scope wrappers to semantic classes, keep only the chosen data-p branch, substitute range literals); (4) unwrap the accepted content and drop every data-impeccable-* / data-p-* attribute; (5) delete the inline <style>, the param-values comment, and both markers plus dead @scope rules. Then run node ${scriptsPath}/live-complete.mjs --id ${event.id} and verify phase "completed"; it refuses with source_dirty while leftovers remain. Poll again only after that.`;
|
||||
}
|
||||
if (result.handled === true) {
|
||||
return `${prefix}Accept was merged into source mechanically; nothing to clean up. Poll again.`;
|
||||
}
|
||||
if (result.mode === 'fallback') {
|
||||
return `${prefix}The session lived in a generated file, so accept refused to persist there. Write the accepted variant into the true source you identified during Handle fallback, remove the temporary wrapper from the served file, then poll again.`;
|
||||
}
|
||||
if (result.mode === 'error') {
|
||||
if (result.error === 'source_locked') {
|
||||
return `${prefix}The source file is briefly locked by a publisher. Re-run the exact same live-accept.mjs command (idempotent); do NOT hand-edit the file, and do not poll past this.`;
|
||||
}
|
||||
if (result.error === 'accept_receipt_conflict') {
|
||||
return `${prefix}This session already resolved as ${result.priorOperation || 'a prior operation'}; do not edit anything. Run node ${scriptsPath}/live-status.mjs and tell the user what the session resolved to.`;
|
||||
}
|
||||
return `${prefix}Accept failed: ${result.error || 'unknown error'}. Source was not touched; do not hand-edit. Run node ${scriptsPath}/live-status.mjs before continuing.`;
|
||||
}
|
||||
return `${prefix}No mechanical accept result; read ${result.file || 'the session source file'}, find the impeccable markers, and finish the merge by hand. Poll again after.`;
|
||||
}
|
||||
|
||||
/** Boot instructions attached to live.mjs's success payload. */
|
||||
export function bootInstructions({ scriptsPath = '{{scripts_path}}' } = {}) {
|
||||
return `Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run ${pollCmd(scriptsPath)} immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.`;
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 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 { execFileSync } from 'node:child_process';
|
||||
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);
|
||||
// Same criterion as the upward walk (isAppRoot): a live-configured
|
||||
// plain-static site with no bundler markers is still an app, and
|
||||
// missing it here would silently fall back to the wrong root.
|
||||
if (isAppRoot(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 });
|
||||
// The pointer records EVERY app that has booted live in this repo, most
|
||||
// recent first. A single last-boot-wins value made a helper run from the
|
||||
// repo root silently target whichever app booted last, even while an
|
||||
// earlier app's session was the one still live.
|
||||
const entries = readPointerEntries(manifest.repoRoot)
|
||||
.filter((entry) => path.resolve(entry.appRoot) !== path.resolve(manifest.appRoot));
|
||||
entries.unshift({ appRoot: manifest.appRoot, bootedAt: new Date().toISOString() });
|
||||
fs.writeFileSync(pointer, JSON.stringify({ version: 2, appRoots: entries }));
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function readPointerEntries(repoRoot) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(pointerFilePath(repoRoot), 'utf-8'));
|
||||
if (Array.isArray(raw?.appRoots)) {
|
||||
return raw.appRoots.filter((entry) => entry && typeof entry.appRoot === 'string');
|
||||
}
|
||||
// v1 shape: a single { appRoot } value.
|
||||
if (raw && typeof raw.appRoot === 'string') return [{ appRoot: raw.appRoot }];
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the app's live helper server is recorded and its pid is alive.
|
||||
* A liveness signal alone misclassifies a REUSED pid (helper died without
|
||||
* removing server.json, the OS handed the pid to something else), so the
|
||||
* process's command line must also look like a node process; that removes
|
||||
* reuse by arbitrary processes. A pid reused by another node process remains
|
||||
* a residual false positive, which the multi-app warning and --target
|
||||
* escape hatch cover.
|
||||
*/
|
||||
function hasLiveServer(appRoot) {
|
||||
let pid;
|
||||
let port;
|
||||
let token;
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(path.join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8'));
|
||||
if (!info || typeof info.pid !== 'number') return false;
|
||||
pid = info.pid;
|
||||
port = Number(info.port);
|
||||
token = typeof info.token === 'string' ? info.token : null;
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
// EPERM: the process exists but is not signalable by this user.
|
||||
if (err?.code !== 'EPERM') return false;
|
||||
}
|
||||
// Liveness alone misclassifies a REUSED pid, and a bare TCP connect
|
||||
// misclassifies a coincidental listener on a reused port. The decisive
|
||||
// signal is IDENTITY: the helper answers its authenticated /status
|
||||
// endpoint with the token server.json records; nothing else on that port
|
||||
// can. The probe is a spawned node one-liner so it works identically on
|
||||
// every platform.
|
||||
if (Number.isInteger(port) && port > 0 && token) {
|
||||
try {
|
||||
execFileSync(process.execPath, ['-e', [
|
||||
"const req = require('node:http').get({ host: '127.0.0.1', port: Number(process.argv[1]), path: '/status?token=' + encodeURIComponent(process.argv[2]), timeout: 1200 }, (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); });",
|
||||
"req.on('timeout', () => { req.destroy(); process.exit(1); });",
|
||||
"req.on('error', () => process.exit(1));",
|
||||
].join(''), String(port), token], { timeout: 4000, stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Every server.json this codebase has ever written records port + token
|
||||
// (see writeLiveServerInfo). A record without them is malformed or foreign
|
||||
// and cannot be authenticated, so it does not count as a live helper;
|
||||
// resolution falls to the durable-session tier, which is the correct
|
||||
// recovery path for a stopped or crashed helper anyway.
|
||||
return false;
|
||||
}
|
||||
|
||||
const TERMINAL_SESSION_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
/**
|
||||
* True when the app's durable session store holds a session that is not
|
||||
* terminal. With every helper server stopped, this is what distinguishes
|
||||
* "the app whose interrupted session the user is trying to recover" from an
|
||||
* app that merely booted more recently.
|
||||
*/
|
||||
function hasActiveDurableSession(appRoot) {
|
||||
const dir = path.join(appRoot, '.impeccable', 'live', 'sessions');
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.snapshot.json')) continue;
|
||||
try {
|
||||
const snapshot = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
||||
if (snapshot?.phase && !TERMINAL_SESSION_PHASES.has(snapshot.phase)) return true;
|
||||
} catch { /* skip unreadable snapshots */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Several apps in one repo may have booted live. Preference order:
|
||||
// a running helper server, then an app whose durable store still holds
|
||||
// a non-terminal session (the stopped session the user is recovering),
|
||||
// then the most recent boot. A stale pointer entry must never redirect
|
||||
// status/poll/accept onto the wrong app's session store.
|
||||
const candidates = readPointerEntries(gitRoot)
|
||||
.map((entry) => readManifestAt(entry.appRoot))
|
||||
.filter(Boolean);
|
||||
if (candidates.length > 0) {
|
||||
const liveApps = candidates.filter((manifest) => hasLiveServer(manifest.appRoot));
|
||||
const recoveringApps = liveApps.length > 0
|
||||
? liveApps
|
||||
: candidates.filter((manifest) => hasActiveDurableSession(manifest.appRoot));
|
||||
const tier = recoveringApps.length > 0 ? recoveringApps : candidates;
|
||||
// Multiple apps qualifying at the same tier is inherent ambiguity:
|
||||
// intent is unknowable from the repo root. The choice stays
|
||||
// deterministic (most recent boot first), but it must be LOUD, not
|
||||
// silent, so the agent can re-anchor when it meant the other app.
|
||||
if (tier.length > 1) {
|
||||
const chosen = tier[0].appRoot;
|
||||
const others = tier.slice(1).map((manifest) => manifest.appRoot).join(', ');
|
||||
process.stderr.write(
|
||||
`[impeccable live] Multiple apps in this repo have live state; using ${chosen}. `
|
||||
+ `Other candidate(s): ${others}. Run from the app directory (or pass --target) to address a specific app.\n`,
|
||||
);
|
||||
}
|
||||
return { manifest: tier[0], source: 'pointer' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = resolveRoots({ cwd: absCwd, targetPath });
|
||||
if (fresh.selection) return { selection: fresh.selection, source: 'fresh' };
|
||||
return { manifest: fresh.manifest, source: 'fresh' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a `--target <path>` / `--target=<path>` pair from an argv array,
|
||||
* returning the value and removing the tokens so downstream flag parsers
|
||||
* (which do not know the option) never see them.
|
||||
*/
|
||||
export function consumeTargetArg(argv = process.argv) {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--target') {
|
||||
const value = argv[i + 1];
|
||||
// A --target with no usable value must not degrade into implicit root
|
||||
// selection: these helpers mutate session state, and "the most recent
|
||||
// app" is exactly what the caller was trying NOT to get.
|
||||
if (typeof value !== 'string' || value === '' || value.startsWith('--')) {
|
||||
throw new Error('--target requires a path value (use --target <path> or --target=<path>)');
|
||||
}
|
||||
argv.splice(i, 2);
|
||||
return value;
|
||||
}
|
||||
if (typeof arg === 'string' && arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value === '') {
|
||||
throw new Error('--target requires a path value (use --target <path> or --target=<path>)');
|
||||
}
|
||||
argv.splice(i, 1);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. An explicit `--target <path>` on the helper's command line
|
||||
* overrides pointer resolution, which is what disambiguates a repo with
|
||||
* several live apps (the multi-app warning names this escape hatch, so it
|
||||
* has to actually work on every helper). Returns the manifest. On selection
|
||||
* ambiguity it stays in the current directory (the boot flow handles
|
||||
* prompting); a malformed --target exits with an error instead of silently
|
||||
* falling back to implicit selection, which could mutate the wrong app.
|
||||
*/
|
||||
export function enterLiveRoot(cwd = process.cwd()) {
|
||||
let targetPath;
|
||||
try {
|
||||
targetPath = consumeTargetArg(process.argv);
|
||||
} catch (err) {
|
||||
console.error(`[impeccable live] ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const resolved = resolveLiveRoots(cwd, targetPath ? { targetPath } : {});
|
||||
if (!resolved.manifest) return null;
|
||||
const appRoot = resolved.manifest.appRoot;
|
||||
if (path.resolve(cwd) !== path.resolve(appRoot)) {
|
||||
// Failing to land on the resolved appRoot must be fatal: a helper that
|
||||
// silently keeps its ambient cwd derives server, session, and source
|
||||
// paths from a different project and mutates the wrong state. A manifest
|
||||
// pointing at a deleted directory is stale ambient truth, not a reason
|
||||
// to guess.
|
||||
if (!isDir(appRoot)) {
|
||||
console.error(`[impeccable live] resolved app root does not exist: ${appRoot} (stale roots manifest? re-run the live boot, or pass --target <path>)`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
process.chdir(appRoot);
|
||||
} catch (err) {
|
||||
console.error(`[impeccable live] could not enter app root ${appRoot}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
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,116 @@ 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;
|
||||
},
|
||||
/**
|
||||
* True when a journal exists for the id in either root. appendEvent
|
||||
* CREATES a journal for any id it is handed, so callers that should only
|
||||
* ever touch existing sessions (browser checkpoints, mount acks) check
|
||||
* here first — otherwise a stale id from another project's browser
|
||||
* storage materializes a ghost session in this store.
|
||||
*/
|
||||
has(id) {
|
||||
if (!id || typeof id !== 'string') return false;
|
||||
return fs.existsSync(getJournalPath(rootDir, id))
|
||||
|| fs.existsSync(getJournalPath(legacyRootDir, id));
|
||||
},
|
||||
/**
|
||||
* 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 +162,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 +173,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 +251,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 +309,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 +318,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 +333,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 +392,45 @@ 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);
|
||||
// The failure needs an agent reply, so it must survive a helper
|
||||
// restart the same way a generate does. Never clobber a still-pending
|
||||
// generate: a progressive publish can fail an early mount while the
|
||||
// generate event itself is still leased.
|
||||
if (!next.pendingEvent) {
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
}
|
||||
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 +553,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,961 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Identifiers that resolve in ANY module scope. They are neither hydratable
|
||||
// props nor evidence of route coupling, so they count as neither free nor
|
||||
// bound: `{Math.round(x)}` must not mint a prop named `round`, and
|
||||
// `{fmt(stage.label)}` must not pass as global-only.
|
||||
const GLOBAL_IDENTIFIERS = new Set([
|
||||
'Math', 'JSON', 'Date', 'Intl', 'Number', 'String', 'Boolean', 'Array',
|
||||
'Object', 'Map', 'Set', 'Promise', 'RegExp', 'NaN', 'Infinity', 'undefined',
|
||||
'isNaN', 'isFinite', 'parseInt', 'parseFloat', 'encodeURIComponent',
|
||||
'decodeURIComponent', 'console', 'window', 'document', 'navigator',
|
||||
'location', 'structuredClone', 'crypto',
|
||||
]);
|
||||
|
||||
function classifyRoots(node, scopes) {
|
||||
const roots = collectRootIdentifiers(node);
|
||||
let bound = 0;
|
||||
let free = 0;
|
||||
for (const name of roots) {
|
||||
if (GLOBAL_IDENTIFIERS.has(name)) continue;
|
||||
if (scopes.some((scope) => scope.has(name))) bound++;
|
||||
else free++;
|
||||
}
|
||||
return { bound, free };
|
||||
}
|
||||
|
||||
function isFree(node, scopes) {
|
||||
const { bound, free } = classifyRoots(node, scopes);
|
||||
return free > 0 && bound === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* An expression mixing loop-bound and outer free identifiers (e.g.
|
||||
* `{fmt(stage.label)}` where `fmt` lives in the route script) can neither
|
||||
* become a prop (the bound part varies per item) nor survive detachment
|
||||
* verbatim (the free name is undeclared in the preview and throws at mount,
|
||||
* past the compile gate, because globals make it legal to the compiler).
|
||||
* Source-preview mode is the only correct home for it.
|
||||
*/
|
||||
function failOnMixedExpression(node, scopes, analysis, source) {
|
||||
const { bound, free } = classifyRoots(node, scopes);
|
||||
if (bound > 0 && free > 0) {
|
||||
analysis.fail(`expression mixing loop and outer identifiers ({${exprText(source, node).slice(0, 60)}}) requires source-preview mode`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
||||
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 (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
||||
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 && failOnMixedExpression(decl.init, scopes, analysis, analysis.source)) return;
|
||||
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 (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
||||
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 (failOnMixedExpression(node.test, scopes, analysis, analysis.source)) return;
|
||||
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 (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
|
||||
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 (failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) return;
|
||||
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': {
|
||||
const expr = attr.expression;
|
||||
if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
|
||||
if (expr && isFree(expr, scopes)) {
|
||||
const text = exprText(analysis.source, expr);
|
||||
// The directive's class name is literal, so the live DOM answers
|
||||
// the condition directly: the class is either present or not.
|
||||
const entry = analysis.propFor(text, 'condition', {
|
||||
probe: { className: attr.name },
|
||||
});
|
||||
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'StyleDirective': {
|
||||
// Unlike ClassDirective, a style directive stores its value in
|
||||
// attribute shape: `true` for the shorthand, else an array of parts.
|
||||
const parts = attr.value === true ? [] : (Array.isArray(attr.value) ? attr.value : [attr.value]);
|
||||
for (const part of parts) {
|
||||
if (part?.type === 'ExpressionTag'
|
||||
&& failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const dynamic = parts.some((part) => part?.type === 'ExpressionTag' && isFree(part.expression, scopes));
|
||||
const shorthandFree = attr.value === true && isFree({ type: 'Identifier', name: attr.name }, scopes);
|
||||
if (dynamic || shorthandFree) {
|
||||
// style:opacity={x} carries a css VALUE, not a boolean, and the
|
||||
// computed value on the live element is not reliably recoverable in
|
||||
// the shape the expression produced. A falsified style is worse
|
||||
// than an HMR-resetting preview.
|
||||
analysis.fail(`style:${attr.name} with a dynamic value requires source-preview mode`);
|
||||
}
|
||||
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 && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
|
||||
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 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 attrSlots = [];
|
||||
// The hydration item is a SHALLOW object whose string fields are the exact
|
||||
// property names the markup accesses, filled from the rendered page. That
|
||||
// model supports one item access per slot, optionally wrapped in a global
|
||||
// transform ({Math.round(r.score)} hydrates `score`). Shapes it cannot
|
||||
// represent split two ways: CRASHY ones would throw at mount time against a
|
||||
// shallow item (deep paths like r.meta.label, method calls like r.format())
|
||||
// and force the source-preview fallback; LOSSY ones render wrong but safe
|
||||
// (bare {r}, multi-access expressions that would double their text) and
|
||||
// also fall back in text position, where the damage is visible.
|
||||
const boundAs = (name, scopeInfos) => {
|
||||
for (let i = scopeInfos.length - 1; i >= 0; i--) {
|
||||
const info = scopeInfos[i];
|
||||
if (info.indexName === name) return 'index';
|
||||
if (info.itemName === name) return 'item';
|
||||
if (info.names.has(name)) return 'field';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const slotKeysOf = (expression, scopeInfos) => {
|
||||
const keys = new Set();
|
||||
let crashy = false;
|
||||
let lossy = false;
|
||||
let touches = false;
|
||||
const visit = (node, ctx) => {
|
||||
if (!node || typeof node !== 'object' || crashy) return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item, {});
|
||||
return;
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'Identifier': {
|
||||
const kind = boundAs(node.name, scopeInfos);
|
||||
if (!kind) return;
|
||||
touches = true;
|
||||
if (kind === 'index') return; // the runtime each provides it
|
||||
if (kind === 'item') { lossy = true; return; } // bare item reference
|
||||
if (ctx.callee) { crashy = true; return; } // field() on a hydrated string
|
||||
keys.add(node.name); // destructured context field
|
||||
return;
|
||||
}
|
||||
case 'MemberExpression': {
|
||||
if (
|
||||
!node.computed
|
||||
&& node.object?.type === 'Identifier'
|
||||
&& boundAs(node.object.name, scopeInfos) === 'item'
|
||||
&& node.property?.type === 'Identifier'
|
||||
) {
|
||||
touches = true;
|
||||
// item.a.b or item.method(): a shallow string field throws here.
|
||||
if (ctx.memberObject || ctx.callee) { crashy = true; return; }
|
||||
keys.add(node.property.name);
|
||||
return;
|
||||
}
|
||||
visit(node.object, { memberObject: true });
|
||||
if (node.computed) visit(node.property, {});
|
||||
return;
|
||||
}
|
||||
case 'CallExpression':
|
||||
visit(node.callee, { callee: true });
|
||||
for (const arg of node.arguments || []) visit(arg, {});
|
||||
return;
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionExpression': {
|
||||
// Closures cannot hydrate; only lossy when they capture the item.
|
||||
const roots = collectRootIdentifiers(node);
|
||||
if ([...roots].some((name) => boundAs(name, scopeInfos))) { touches = true; lossy = true; }
|
||||
return;
|
||||
}
|
||||
case 'Property':
|
||||
if (node.computed) visit(node.key, {});
|
||||
visit(node.value, {});
|
||||
return;
|
||||
default: {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
|
||||
visit(node[key], {});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(expression, {});
|
||||
if (crashy) return { crashy: true };
|
||||
if (lossy || keys.size > 1) return { lossy: true };
|
||||
if (!touches || keys.size === 0) return { skip: true };
|
||||
return { key: [...keys][0] };
|
||||
};
|
||||
const staticClassesOf = (el) => {
|
||||
const classes = [];
|
||||
for (const attr of el?.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 classes;
|
||||
};
|
||||
const scopeInfoOf = (eachNode) => {
|
||||
const names = new Set();
|
||||
if (eachNode.context) collectPatternNames(eachNode.context, names);
|
||||
return {
|
||||
names,
|
||||
itemName: eachNode.context?.type === 'Identifier' ? eachNode.context.name : null,
|
||||
indexName: eachNode.index || null,
|
||||
};
|
||||
};
|
||||
const walkForSlots = (fragment, scopeInfos) => {
|
||||
for (const child of fragment?.nodes || []) {
|
||||
if (child.type === 'ExpressionTag') {
|
||||
const slot = slotKeysOf(child.expression, scopeInfos);
|
||||
if (slot.crashy || slot.lossy) { nestedUnsupported = true; continue; }
|
||||
if (slot.skip) continue;
|
||||
textSlots.push({ key: slot.key, expr: exprText(source, child.expression) });
|
||||
} else if (child.type === 'RegularElement' || child.type === 'SvelteElement') {
|
||||
// Bound values in ATTRIBUTES (href={link.href}, src={item.img}) are
|
||||
// part of the item too: the browser reads the rendered attribute off
|
||||
// the live element, so the preview does not mount with empty links.
|
||||
// Only a single-expression attribute hydrates exactly; a mixed value
|
||||
// ("card {r.status}") stays unhydrated because the rendered attribute
|
||||
// is not separable into its parts, which was the prior behavior.
|
||||
for (const attr of child.attributes || []) {
|
||||
if (attr.type !== 'Attribute' || attr.value === true) continue;
|
||||
if (HANDLER_ATTR_RE.test(attr.name)) continue; // functions cannot hydrate
|
||||
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
|
||||
const exprParts = parts.filter((part) => part?.type === 'ExpressionTag');
|
||||
for (const part of exprParts) {
|
||||
const slot = slotKeysOf(part.expression, scopeInfos);
|
||||
if (slot.crashy) { nestedUnsupported = true; continue; }
|
||||
if (slot.skip || slot.lossy) continue;
|
||||
if (parts.length !== 1) continue; // mixed static+dynamic value
|
||||
attrSlots.push({
|
||||
key: slot.key,
|
||||
expr: exprText(source, part.expression),
|
||||
attr: attr.name,
|
||||
tag: child.name || null,
|
||||
classes: staticClassesOf(child),
|
||||
});
|
||||
}
|
||||
}
|
||||
walkForSlots(child.fragment, scopeInfos);
|
||||
continue;
|
||||
} else if (child.type === 'EachBlock') {
|
||||
const roots = collectRootIdentifiers(child.expression);
|
||||
const boundNested = [...roots].some((name) => boundAs(name, scopeInfos));
|
||||
if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet
|
||||
walkForSlots(child.body, [...scopeInfos, scopeInfoOf(child)]);
|
||||
} else if (child.type === 'IfBlock') {
|
||||
walkForSlots(child.consequent, scopeInfos);
|
||||
if (child.alternate) walkForSlots(child.alternate, scopeInfos);
|
||||
} else if (child.fragment) {
|
||||
walkForSlots(child.fragment, scopeInfos);
|
||||
}
|
||||
}
|
||||
};
|
||||
walkForSlots(body, [scopeInfoOf(node)]);
|
||||
|
||||
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,
|
||||
attrSlots,
|
||||
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: 'per-item content (nested blocks or expressions) this preview cannot hydrate requires 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);
|
||||
const bound = new Set();
|
||||
if (node.context) collectPatternNames(node.context, bound);
|
||||
if (node.index) bound.add(node.index);
|
||||
// The key evaluates per item, so the loop context and index are in
|
||||
// scope there. Visiting it with outer scopes only let a contract
|
||||
// prop that shares a loop binding's name rewrite the key.
|
||||
if (node.key) visitExpr(node.key, [...nextScopes, bound]);
|
||||
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,25 +191,55 @@ 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 seeded = extractMatchingSourceCss(
|
||||
safeReadSource(path.resolve(cwd, sourceFile)),
|
||||
originalMarkup,
|
||||
);
|
||||
const seededCss = seeded.css;
|
||||
// The preview compiles in isolation, so NONE of these source rules applied
|
||||
// to what the user approved. Accept enforces that preview truth: any of
|
||||
// them the variant does not re-declare is superseded and removed, instead
|
||||
// of re-attaching to the accepted markup through kept class names (the
|
||||
// ".decisions grid grabs the new board" failure). Only the CLASS-matched
|
||||
// selectors are candidates; tag rules style shared route elements.
|
||||
const seededSelectors = [...seeded.supersedable];
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'svelte-component',
|
||||
contractVersion: 2,
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
count,
|
||||
propContract: contract,
|
||||
originalMarkup,
|
||||
seededSelectors,
|
||||
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 +247,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,9 +256,100 @@ export function scaffoldSvelteComponentSession({
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract: contract,
|
||||
// Inlined so the generate event's scaffold payload carries the stub
|
||||
// shape; the agent edits vN.svelte in place instead of spending reads on
|
||||
// the manifest and stub files (or deleting and recreating them).
|
||||
stubMarkup: analysis.markupWithProps,
|
||||
seededCss,
|
||||
};
|
||||
}
|
||||
|
||||
function safeReadSource(filePath) {
|
||||
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
|
||||
}
|
||||
|
||||
function escapeSelectorToken(token) {
|
||||
return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Returns { css, supersedable }. `css` is every matching rule (class OR tag
|
||||
* matched). `supersedable` holds only the CLASS-matched selectors: those are
|
||||
* the accept-time removal candidates. Tag selectors (h1, a, p) style shared
|
||||
* elements across the whole route, so they seed the preview but are never
|
||||
* candidates for removal.
|
||||
*/
|
||||
export function extractMatchingSourceCss(routeSource, originalMarkup) {
|
||||
const empty = { css: '', supersedable: new Set() };
|
||||
const styleMatch = String(routeSource || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
|
||||
if (!styleMatch) return empty;
|
||||
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 empty;
|
||||
|
||||
// Token-boundary matching, never substring: `.btn` must not match
|
||||
// `.btn-primary`, and `.stage` must not match `.stages`. A substring hit
|
||||
// seeds a rule that never styled the pick, and a falsely seeded selector
|
||||
// becomes an accept-time DELETION of a hand-written rule.
|
||||
const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])'));
|
||||
const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i'));
|
||||
const classMatches = (selector) => classRes.some((re) => re.test(selector));
|
||||
const tagMatches = (selector) => tagRes.some((re) => re.test(selector));
|
||||
|
||||
const supersedable = new Set();
|
||||
const ruleMatches = (prelude) => {
|
||||
let matched = false;
|
||||
for (const selector of splitSelectorList(prelude)) {
|
||||
if (classMatches(selector)) {
|
||||
matched = true;
|
||||
supersedable.add(normalizeSelector(selector));
|
||||
} else if (tagMatches(selector)) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
};
|
||||
|
||||
const pick = (nodes) => {
|
||||
const kept = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'rule' && ruleMatches(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 { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable };
|
||||
}
|
||||
|
||||
function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) {
|
||||
const propsComment = contract.length > 0
|
||||
? `\n<!-- Props: ${contract.map((c) => `${c.prop} (${c.kind}) <- {${c.expr}}`).join(', ')} -->\n`
|
||||
: '';
|
||||
// The guard comments must never contain the literal "<style" character
|
||||
// sequence: agents (and the fake test agent) locate the style block with
|
||||
// string searches, and a mention inside a comment truncates their surgery
|
||||
// mid-comment.
|
||||
const css = seededCss
|
||||
? `\n<style>\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>\n`
|
||||
: `\n<style>\n /* Variant ${variantNum}: add all CSS inside THIS block. Svelte allows exactly\n one top-level style element; a second one is a compile error. */\n</style>\n`;
|
||||
return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
@@ -213,7 +380,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 +409,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 +630,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 +677,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 +705,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 +713,235 @@ 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: [], superseded: [] };
|
||||
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');
|
||||
|
||||
// Preview truth: the detached preview never applied the source rules that
|
||||
// styled the replaced selection, so the user approved a design without
|
||||
// them. Any seeded selector the variant did not re-declare is superseded;
|
||||
// left in place it re-attaches through kept class names (the accepted root
|
||||
// keeps its original classes) and re-layouts markup it no longer owns.
|
||||
//
|
||||
// Removal is bounded by ownership: a selector whose classes are still used
|
||||
// by route markup OUTSIDE the replaced region does not belong to the pick
|
||||
// alone, and removing it would strip styling from markup this accept never
|
||||
// touched. Keeping it risks a visible re-attachment quirk on the accepted
|
||||
// region; deleting it breaks the rest of the route. Keep it.
|
||||
const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)]
|
||||
.join('\n')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, '');
|
||||
const outsideClasses = new Set();
|
||||
{
|
||||
const attrRe = /class\s*=\s*(["'])(.*?)\1/g;
|
||||
let cm;
|
||||
while ((cm = attrRe.exec(outsideMarkup))) {
|
||||
for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls);
|
||||
}
|
||||
const directiveRe = /class:([A-Za-z0-9_-]+)/g;
|
||||
while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]);
|
||||
}
|
||||
const usedOutsideReplacedRegion = (selector) => {
|
||||
const classTokenRe = /\.([A-Za-z0-9_-]+)/g;
|
||||
let tm;
|
||||
while ((tm = classTokenRe.exec(selector))) {
|
||||
if (outsideClasses.has(tm[1])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const incomingSelectors = collectAllSelectors(bakedCss);
|
||||
const superseded = (manifest.seededSelectors || [])
|
||||
.map((selector) => normalizeSelector(selector))
|
||||
.filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector));
|
||||
if (superseded.length > 0) {
|
||||
const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded));
|
||||
finalText = scrubbed.text;
|
||||
cssStats.superseded = scrubbed.removed;
|
||||
}
|
||||
|
||||
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 or the preview-truth supersession
|
||||
// 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,
|
||||
...cssStats.superseded,
|
||||
]);
|
||||
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] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every rule whose (normalized) selector list is fully contained in
|
||||
* `selectors` from the component's style block, at any at-rule nesting depth.
|
||||
* Rules that mix doomed and surviving selectors keep the survivors.
|
||||
*/
|
||||
export function removeSelectorsFromSvelteSource(sourceText, selectors) {
|
||||
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) return { text, removed: [] };
|
||||
|
||||
const removed = [];
|
||||
const transform = (nodes) => {
|
||||
const kept = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'rule') {
|
||||
const survivors = [];
|
||||
for (const selector of splitSelectorList(node.prelude)) {
|
||||
if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector));
|
||||
else survivors.push(selector);
|
||||
}
|
||||
if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') });
|
||||
} else if (node.type === 'at' && node.children) {
|
||||
const children = transform(node.children);
|
||||
if (children.length > 0) kept.push({ ...node, children });
|
||||
} else {
|
||||
kept.push(node);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
|
||||
const nodes = transform(parseStylesheet(lastMatch[1]));
|
||||
if (removed.length === 0) return { text, removed };
|
||||
const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
|
||||
const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>`;
|
||||
return {
|
||||
text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length),
|
||||
removed,
|
||||
};
|
||||
}
|
||||
|
||||
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 +972,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 +980,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 +998,10 @@ function inlineSvelteComponentInsertAccept({
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
const verify = verifyAcceptedSource(newLines.join('\n'));
|
||||
return {
|
||||
handled: true,
|
||||
verify,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
@@ -729,18 +1104,159 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Compile-check every variant component of a session with the app's own
|
||||
* compiler, BEFORE the browser ever imports them. A variant that does not
|
||||
* compile (the classic: a second top-level <style> appended next to the
|
||||
* seeded one) used to surface as a red Vite overlay in the user's page plus
|
||||
* a mount-failure round trip; bounced at publish time it is a private
|
||||
* agent-side fix with the exact file and line.
|
||||
*/
|
||||
export function compileCheckVariants(id, cwd = process.cwd()) {
|
||||
const manifest = findSvelteComponentManifest(id, cwd);
|
||||
if (!manifest || !manifest.manifestPath) return { ok: true, failures: [], checked: 0 };
|
||||
const compiler = loadSvelteCompiler(cwd);
|
||||
if (!compiler || typeof compiler.compile !== 'function') return { ok: true, failures: [], checked: 0 };
|
||||
const sessionDir = path.dirname(manifest.manifestPath);
|
||||
const failures = [];
|
||||
let checked = 0;
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(sessionDir); } catch { return { ok: true, failures: [], checked: 0 }; }
|
||||
for (const name of entries) {
|
||||
if (!/^v\d+\.svelte$/.test(name)) continue;
|
||||
checked++;
|
||||
try {
|
||||
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
||||
compiler.compile(fs.readFileSync(path.join(sessionDir, name), 'utf-8'), { generate: false });
|
||||
} catch (err) {
|
||||
failures.push({
|
||||
file: `${manifest.componentDir}/${name}`,
|
||||
line: err?.start?.line ?? null,
|
||||
column: err?.start?.column ?? null,
|
||||
message: String(err?.message || err).split('\n')[0].slice(0, 300),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: failures.length === 0, failures, checked };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
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(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');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* actual live UI remains the shared plain-DOM browser chrome.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -14,6 +15,28 @@ export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
|
||||
// Matches the import at ANY revision (or none). [ \t]* bounds only, never
|
||||
// \s*: a greedy \s* after the statement swallowed the next line's
|
||||
// indentation on removal, leaving a formatting scar in user layouts.
|
||||
const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm;
|
||||
|
||||
/**
|
||||
* The import specifier carries a token-derived revision query. The adapter
|
||||
* component embeds the helper token, and Vite (client AND SSR) can keep
|
||||
* serving a stale compiled module after the file is rewritten on a helper
|
||||
* restart; the browser then requests /live.js with a rotated-out token and
|
||||
* gets a 401 with no picker. A changed specifier is a different module id,
|
||||
* which no cache survives.
|
||||
*/
|
||||
export function svelteRootImportLine(rev) {
|
||||
if (!rev) return SVELTE_ROOT_IMPORT;
|
||||
return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';";
|
||||
}
|
||||
|
||||
export function svelteAdapterRev(token) {
|
||||
if (!token) return null;
|
||||
return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
const appHtml = findSvelteKitAppHtml(cwd, config);
|
||||
@@ -50,7 +73,7 @@ export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, co
|
||||
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
|
||||
const layoutExisted = fs.existsSync(layoutAbs);
|
||||
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
|
||||
const after = patchSvelteLayout(before);
|
||||
const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) });
|
||||
fs.writeFileSync(layoutAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
@@ -94,15 +117,27 @@ export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null
|
||||
};
|
||||
}
|
||||
|
||||
export function patchSvelteLayout(content) {
|
||||
export function patchSvelteLayout(content, { rev = null } = {}) {
|
||||
let out = String(content || '');
|
||||
if (!out.includes(SVELTE_ROOT_IMPORT)) {
|
||||
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
|
||||
if (scriptMatch) {
|
||||
const insertAt = scriptMatch.index + scriptMatch[0].length;
|
||||
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
|
||||
} else {
|
||||
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
|
||||
const importLine = svelteRootImportLine(rev);
|
||||
if (!out.includes(importLine)) {
|
||||
// An import at an older revision is replaced in place, keeping its
|
||||
// indentation; only a layout with no impeccable import gets an insert.
|
||||
let replaced = false;
|
||||
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => {
|
||||
if (replaced) return '';
|
||||
replaced = true;
|
||||
const indent = (line.match(/^[ \t]*/) || [''])[0];
|
||||
return indent + importLine + '\n';
|
||||
});
|
||||
if (!replaced) {
|
||||
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
|
||||
if (scriptMatch) {
|
||||
const insertAt = scriptMatch.index + scriptMatch[0].length;
|
||||
out = out.slice(0, insertAt) + '\n ' + importLine + out.slice(insertAt);
|
||||
} else {
|
||||
out = `<script>\n ${importLine}\n</script>\n\n` + out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +166,8 @@ export function unpatchSvelteLayout(content) {
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '$1');
|
||||
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
|
||||
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
|
||||
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, '');
|
||||
out = out.replace(/<script>\s*<\/script>[ \t]*\r?\n?/g, '');
|
||||
return out.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
@@ -193,6 +228,11 @@ export function buildSvelteLiveRootComponent(port, token) {
|
||||
script.src = LIVE_URL;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveScript = 'true';
|
||||
script.onerror = () => console.error(
|
||||
'[impeccable] live.js failed to load from ' + LIVE_URL
|
||||
+ ' (helper down, or the token rotated while a stale adapter module was cached).'
|
||||
+ ' Re-run the live boot, then reload this page.'
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -95,6 +95,17 @@ describe('ci-test-plan', () => {
|
||||
assert.match(workflow, /live-e2e-accept-cleanup:/);
|
||||
assert.match(workflow, /live-svelte-adapter-deepseek:/);
|
||||
});
|
||||
it('schedule events run only the deterministic suites plus the full live-e2e matrix', () => {
|
||||
const outputs = runPlan({ GITHUB_EVENT_NAME: 'schedule' });
|
||||
assert.equal(outputs.live_e2e, 'true');
|
||||
assert.equal(outputs.live_e2e_accept_cleanup, 'false');
|
||||
assert.equal(outputs.skill_behavior, 'false');
|
||||
assert.equal(outputs.live_svelte_adapter_deepseek, 'false');
|
||||
assert.equal(outputs.cli_remote_e2e, 'false');
|
||||
assert.equal(outputs.core, 'true');
|
||||
assert.equal(outputs.live, 'true');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function runPlan(env) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
{
|
||||
"name": "Astro 7 + Vite 7",
|
||||
"config": {
|
||||
"files": ["src/layouts/Layout.astro"],
|
||||
"files": [
|
||||
"src/layouts/Layout.astro"
|
||||
],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["src/layouts/Layout.astro", "src/pages/index.astro", "astro.config.mjs"],
|
||||
"sourceFiles": [
|
||||
"src/layouts/Layout.astro",
|
||||
"src/pages/index.astro",
|
||||
"astro.config.mjs"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero in pages/index.astro",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"args": {
|
||||
"classes": "hero-title",
|
||||
"tag": "h1"
|
||||
},
|
||||
"expectedFile": "src/pages/index.astro"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "astro", "dev", "--host", "127.0.0.1"],
|
||||
"install": [
|
||||
"npm",
|
||||
"install",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--loglevel=error"
|
||||
],
|
||||
"devCommand": [
|
||||
"npx",
|
||||
"astro",
|
||||
"dev",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--ignore-lock"
|
||||
],
|
||||
"readyPattern": "Local\\s+https?://[^:\\s]+:(\\d+)",
|
||||
"readyTimeoutMs": 180000,
|
||||
"probe": {
|
||||
@@ -28,7 +50,11 @@
|
||||
"sourceFile": "src/pages/index.astro"
|
||||
},
|
||||
"missedDoneReloadScenario": {
|
||||
"sourceFile": "src/pages/index.astro"
|
||||
"sourceFile": "src/pages/index.astro",
|
||||
"knownLimitation": "Fails identically at origin/main once Astro 7's agent-detection daemon mode is bypassed (the reload into a wrapper-only page never happens under the deferred source write). Pre-existing; tracked separately from the live v2 work that unmasked it."
|
||||
},
|
||||
"env": {
|
||||
"ASTRO_DEV_BACKGROUND": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
{
|
||||
"name": "Vite 8 + React + plain CSS",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"files": [
|
||||
"index.html"
|
||||
],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["index.html", "src/App.jsx", "src/main.jsx", "src/styles.css", "vite.config.js"],
|
||||
"sourceFiles": [
|
||||
"index.html",
|
||||
"src/App.jsx",
|
||||
"src/main.jsx",
|
||||
"src/styles.css",
|
||||
"vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero title in source JSX",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"args": {
|
||||
"classes": "hero-title",
|
||||
"tag": "h1"
|
||||
},
|
||||
"expectedFile": "src/App.jsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"install": [
|
||||
"npm",
|
||||
"install",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--loglevel=error"
|
||||
],
|
||||
"devCommand": [
|
||||
"npx",
|
||||
"vite",
|
||||
"--host",
|
||||
"127.0.0.1"
|
||||
],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"probe": {
|
||||
@@ -33,7 +55,13 @@
|
||||
"manualEditScenarios": [
|
||||
{
|
||||
"name": "React headless manual Apply hard batch",
|
||||
"element": { "selector": "main.page", "position": { "x": 4, "y": 4 } },
|
||||
"element": {
|
||||
"selector": "main.page",
|
||||
"position": {
|
||||
"x": 4,
|
||||
"y": 4
|
||||
}
|
||||
},
|
||||
"applyTimeoutMs": 300000,
|
||||
"refreshAfterApply": true,
|
||||
"expectApplyLoading": true,
|
||||
@@ -148,6 +176,10 @@
|
||||
],
|
||||
"expectedStashCount": 13
|
||||
}
|
||||
]
|
||||
],
|
||||
"orphanedWrapperScenario": {
|
||||
"sourceFile": "src/App.jsx"
|
||||
},
|
||||
"foreignSessionScenario": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
window.__impeccableStatefulMounts = (window.__impeccableStatefulMounts || 0) + 1;
|
||||
});
|
||||
|
||||
const CATALOG = [
|
||||
{ name: 'Design snack', amount: '$12', doc: '/receipts/snack' },
|
||||
{ name: 'Studio coffee', amount: '$8', doc: '/receipts/coffee' },
|
||||
{ name: 'Type license', amount: '$44', doc: '/receipts/type' },
|
||||
];
|
||||
|
||||
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,15 @@
|
||||
<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>
|
||||
<a class="expense-doc" href={expense.doc}>Beleg</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -96,18 +105,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 {
|
||||
|
||||
@@ -1,26 +1,144 @@
|
||||
{
|
||||
"name": "Vite 8 + SvelteKit stateful page",
|
||||
"config": {
|
||||
"files": ["src/app.html"],
|
||||
"files": [
|
||||
"src/app.html"
|
||||
],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["DESIGN.md", "src/app.html", "src/routes/+page.svelte", "src/routes/+layout.svelte", "svelte.config.js", "vite.config.js"],
|
||||
"sourceFiles": [
|
||||
"DESIGN.md",
|
||||
"src/app.html",
|
||||
"src/routes/+page.svelte",
|
||||
"src/routes/+layout.svelte",
|
||||
"svelte.config.js",
|
||||
"vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero title through Svelte component preview",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"args": {
|
||||
"classes": "hero-title",
|
||||
"tag": "h1"
|
||||
},
|
||||
"expectedFile": "node_modules/.impeccable-live/wraptest0/manifest.json",
|
||||
"expectedSourceFile": "src/routes/+page.svelte",
|
||||
"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}",
|
||||
"href={expense.doc}"
|
||||
],
|
||||
"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
|
||||
},
|
||||
"mountedDomProbe": [
|
||||
{
|
||||
"selector": "ul.expense-list a.expense-doc",
|
||||
"attr": "href",
|
||||
"expect": "/receipts/snack"
|
||||
},
|
||||
{
|
||||
"selector": "ul.expense-list strong.expense-name",
|
||||
"expect": "Design snack"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,259 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('prunes past child combinators without truncating the selector list', () => {
|
||||
// The prelude walk must not treat the child combinator as a boundary:
|
||||
// cutting at the `>` of `.wrap > .item` used to rewrite the list from
|
||||
// mid-prelude.
|
||||
const component = `<div class="wrap"><p class="item">x</p></div>\n<style>\n .wrap > .item, .orphan { font-weight: bold; }\n .wrap { padding: 4px; }\n</style>`;
|
||||
const { source, removed } = pruneUnusedSelectors(component, compile);
|
||||
assert.deepEqual(removed, ['.orphan']);
|
||||
assert.match(source, /\.wrap > \.item \{ font-weight: bold; \}/);
|
||||
assert.match(source, /\.wrap \{ padding: 4px; \}/);
|
||||
const { warnings } = compile(source, { generate: false });
|
||||
assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
|
||||
});
|
||||
|
||||
it('removes a fully unused combinator rule without leaving a dangling fragment', () => {
|
||||
// The corruption shape: after a mid-prelude cut, every remaining fragment
|
||||
// equals the flagged selector, so the whole-rule branch deleted from the
|
||||
// cut point and left `.wrap >` dangling in source.
|
||||
const component = `<div class="wrap"><p class="item">x</p></div>\n<style>\n .wrap > .orphan, .orphan { color: blue; }\n .wrap { padding: 4px; }\n</style>`;
|
||||
const { source } = pruneUnusedSelectors(component, compile);
|
||||
assert.doesNotMatch(source, /\.orphan/);
|
||||
assert.doesNotMatch(source, /\.wrap >\s*\{/, 'no dangling combinator fragment');
|
||||
assert.doesNotMatch(source, /\.wrap >\s*$/m, 'no dangling combinator line');
|
||||
assert.match(source, /\.wrap \{ padding: 4px; \}/);
|
||||
const { warnings } = compile(source, { generate: false });
|
||||
assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: toggle branch truth', () => {
|
||||
it('drops valued toggle branches that never matched at preview, keeps on-forms only while on', () => {
|
||||
assert.equal(stripParamSelector('[data-p-flag="false"] .a', 'flag', 'toggle', true), null);
|
||||
assert.equal(stripParamSelector('[data-p-flag="false"] .a', 'flag', 'toggle', false), null);
|
||||
assert.equal(stripParamSelector('[data-p-flag="0"] .a', 'flag', 'toggle', false), null);
|
||||
assert.equal(stripParamSelector('[data-p-flag="on"] .a', 'flag', 'toggle', true), '.a');
|
||||
assert.equal(stripParamSelector('[data-p-flag="on"] .a', 'flag', 'toggle', false), null);
|
||||
assert.equal(stripParamSelector('[data-p-flag] .a', 'flag', 'toggle', true), '.a');
|
||||
assert.equal(stripParamSelector('[data-p-flag] .a', 'flag', 'toggle', false), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: verify precision', () => {
|
||||
it('does not flag user tokens that merely share the p- prefix', () => {
|
||||
const clean = [
|
||||
'<div data-page="3" data-p-count-like data-photo="x">ok</div>',
|
||||
'.a { color: var(--primary); padding: var(--padding, 4px); }',
|
||||
].join('\n');
|
||||
assert.equal(verifyAcceptedSource(clean).clean, true, JSON.stringify(verifyAcceptedSource(clean).findings));
|
||||
});
|
||||
|
||||
it('still flags the exact shapes live mode writes', () => {
|
||||
const dirty = [
|
||||
'<div data-p-density="snug">x</div>',
|
||||
'.a { gap: var(--p-depth, 4px); }',
|
||||
'.b[data-p-flag] { color: red; }',
|
||||
].join('\n');
|
||||
const { clean, findings } = verifyAcceptedSource(dirty);
|
||||
assert.equal(clean, false);
|
||||
assert.equal(findings.length >= 3, true);
|
||||
});
|
||||
});
|
||||
@@ -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,28 @@ 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}?ADOPTABLE_SESSION_PHASES\.has\(String\(session\.phase/,
|
||||
'adoption must be limited to the comparison-phase allowlist',
|
||||
);
|
||||
// Accept/carbonize phases are agent-side work; adopting them resurrects
|
||||
// the bar over a decided comparison (the slow-CI astro accept hang).
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/ADOPTABLE_SESSION_PHASES = new Set\(\[[\s\S]{0,200}?(accept_requested|carbonize|steer|manual_edit)/,
|
||||
'accept, carbonize, steer, and manual-edit phases must not be adoptable',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -20,14 +20,23 @@ import {
|
||||
} from './live-e2e/agents/llm-agent.mjs';
|
||||
|
||||
describe('live-e2e LLM agent provider config', () => {
|
||||
it('defaults to Anthropic and Claude Haiku when no keys are present', () => {
|
||||
it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => {
|
||||
const config = resolveLlmAgentConfig({}, {});
|
||||
|
||||
assert.equal(config.provider, 'openai');
|
||||
assert.equal(config.model, 'gpt-5.6-terra');
|
||||
assert.equal(config.reasoningEffort, 'medium');
|
||||
assert.equal(config.requiredEnv, 'OPENAI_API_KEY');
|
||||
assert.equal(config.apiKey, undefined);
|
||||
assert.equal(config.baseURL, undefined);
|
||||
});
|
||||
|
||||
it('still resolves Anthropic when explicitly selected', () => {
|
||||
const config = resolveLlmAgentConfig({}, { IMPECCABLE_E2E_LLM_PROVIDER: 'anthropic', ANTHROPIC_API_KEY: 'k' });
|
||||
|
||||
assert.equal(config.provider, 'anthropic');
|
||||
assert.equal(config.model, 'claude-haiku-4-5');
|
||||
assert.equal(config.requiredEnv, 'ANTHROPIC_API_KEY');
|
||||
assert.equal(config.apiKey, undefined);
|
||||
assert.equal(config.baseURL, undefined);
|
||||
});
|
||||
|
||||
it('prefers Anthropic when both provider keys are present', () => {
|
||||
|
||||
+682
-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,90 @@ 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);
|
||||
// Optional fixture hook: assert attribute/text values inside the
|
||||
// MOUNTED variant DOM. Component previews hydrate collection items
|
||||
// from the rendered page (text slots and attribute slots); a probe
|
||||
// here catches a preview that mounts but with empty hydrated values,
|
||||
// which every other assertion (style, counter, accept) misses.
|
||||
if (Array.isArray(fixture.runtime.mountedDomProbe)) {
|
||||
for (const probe of fixture.runtime.mountedDomProbe) {
|
||||
const actual = await evaluatePageWithTimeout(
|
||||
page,
|
||||
({ sel, attr }) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
return attr ? el.getAttribute(attr) : (el.textContent || '').trim();
|
||||
},
|
||||
{ sel: probe.selector, attr: probe.attr || null },
|
||||
5_000,
|
||||
'mounted DOM probe',
|
||||
);
|
||||
assert.equal(
|
||||
actual,
|
||||
probe.expect,
|
||||
`mounted variant DOM: ${probe.selector}${probe.attr ? ` [${probe.attr}]` : ' text'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 +630,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 +703,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,12 +754,213 @@ 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) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
const scenarioLimitation = fixture.runtime.missedDoneReloadScenario.knownLimitation;
|
||||
if (scenarioLimitation) {
|
||||
t.diagnostic(`KNOWN LIMITATION: ${scenarioLimitation}`);
|
||||
t.skip(`known limitation: ${scenarioLimitation}`);
|
||||
return;
|
||||
}
|
||||
// Deterministic reproduction of the race the CI astro-vite7 timeout
|
||||
// exposed: the server-side preflight scaffold write triggers a
|
||||
// framework full-reload, and the agent's variant write + `done` SSE
|
||||
@@ -692,7 +980,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 +1019,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) => {
|
||||
@@ -770,6 +1058,148 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('orphan') && fixture.runtime.orphanedWrapperScenario) {
|
||||
it('self-discards an orphaned session when its wrapper is edited out of source', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
// Repro of issue #439: a cycling session is abandoned (no Accept or
|
||||
// Discard), the wrapped region is edited out of source, and the page
|
||||
// reloads. The resumed session used to freeze the picker forever;
|
||||
// recovery required a manual live-complete --discarded. It must now
|
||||
// self-discard and hand the surface back to the picker.
|
||||
const agent = createFakeAgent();
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, appRoot, teardown } = session;
|
||||
const cfg = fixture.runtime.orphanedWrapperScenario;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
const sourceFile = join(appRoot, cfg.sourceFile);
|
||||
const pristine = readFileSync(sourceFile, 'utf-8');
|
||||
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
await clickGo(page);
|
||||
await waitForCyclingRobust(page, 3, { timeout: 60_000, log: (m) => t.diagnostic(m) });
|
||||
const saved = await readLiveSessionStorage(page);
|
||||
assert.ok(saved?.id, 'cycling session persisted to local storage');
|
||||
|
||||
t.diagnostic('Restoring pristine source (simulated external edit that removes the wrapper)');
|
||||
writeFileSync(sourceFile, pristine);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
|
||||
// Resume adopts the cycling session, the source read finds no
|
||||
// wrapper, retries, then self-discards: local session cleared.
|
||||
const deadline = Date.now() + 30_000;
|
||||
for (;;) {
|
||||
const current = await readLiveSessionStorage(page);
|
||||
if (!current?.id) break;
|
||||
if (Date.now() > deadline) throw new Error('orphaned session was never discarded');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
t.diagnostic('Orphaned session discarded; verifying durable phase + picker rearm');
|
||||
|
||||
const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${saved.id}.snapshot.json`);
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
assert.equal(
|
||||
snapshot.phase,
|
||||
'discarded',
|
||||
'an orphaned discard is terminalized server-side without agent involvement',
|
||||
);
|
||||
|
||||
// The regression that mattered: the picker must arm again.
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('foreign') && fixture.runtime.foreignSessionScenario) {
|
||||
it('clears another project\'s leftover browser session instead of resuming it', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
// Repro of the cross-project leak: localStorage is per-ORIGIN, and two
|
||||
// projects routinely reuse the same localhost port across time. A
|
||||
// leftover cycling session from the other project used to be resumed
|
||||
// ("Variants ready" with no user action), and its checkpoints
|
||||
// materialized a ghost session in THIS project's durable store that
|
||||
// kept reattaching after every discard.
|
||||
const agent = createFakeAgent();
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, appRoot, teardown } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
|
||||
const cases = [
|
||||
// Stamped with another app's root: dropped at load time, before
|
||||
// any server roundtrip.
|
||||
{ id: 'f0e1d2c3', appRoot: '/somewhere/else/entirely' },
|
||||
// Legacy shape without a stamp: dropped when the server refuses
|
||||
// its first checkpoint as unknown_session.
|
||||
{ id: 'deadf00d' },
|
||||
];
|
||||
for (const foreign of cases) {
|
||||
t.diagnostic(`Seeding foreign session ${foreign.id}${foreign.appRoot ? ' (stamped)' : ' (legacy shape)'}`);
|
||||
await page.evaluate((saved) => {
|
||||
localStorage.setItem('impeccable-live-session', JSON.stringify(saved));
|
||||
localStorage.removeItem('impeccable-live-session-handled');
|
||||
}, {
|
||||
id: foreign.id,
|
||||
state: 'CYCLING',
|
||||
expected: 3,
|
||||
arrived: 3,
|
||||
visible: 1,
|
||||
sourceFile: 'src/App.jsx',
|
||||
pageUrl: '/',
|
||||
checkpointRevision: 5,
|
||||
...(foreign.appRoot ? { appRoot: foreign.appRoot } : {}),
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
|
||||
const deadline = Date.now() + 20_000;
|
||||
for (;;) {
|
||||
const current = await readLiveSessionStorage(page);
|
||||
if (!current || current.id !== foreign.id) break;
|
||||
if (Date.now() > deadline) throw new Error(`foreign session ${foreign.id} was never cleared`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
const sessionsDir = join(appRoot, '.impeccable/live/sessions');
|
||||
assert.equal(
|
||||
existsSync(join(sessionsDir, `${foreign.id}.jsonl`)),
|
||||
false,
|
||||
`no ghost journal for ${foreign.id} may materialize in this project's store`,
|
||||
);
|
||||
}
|
||||
|
||||
// The surface is genuinely back: a fresh pick must work.
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
|
||||
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
|
||||
for (const scenario of fixture.runtime.manualEditScenarios) {
|
||||
@@ -867,7 +1297,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 +1339,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 +1551,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 +1575,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 +1769,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 +1916,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 +2203,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 +2306,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'),
|
||||
|
||||
+352
-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,59 @@ 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',
|
||||
// No explicit sourceEventType: the server's inferSourceEventType
|
||||
// maps this done onto the pending variant_mount_failed event, so
|
||||
// the failure is acknowledged and leaves the poll queue instead
|
||||
// of being redelivered forever.
|
||||
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;
|
||||
|
||||
@@ -36,6 +36,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..', '..');
|
||||
const LIVE_MD_PATH = path.join(REPO_ROOT, 'skill', 'reference', 'live.md');
|
||||
|
||||
// Frontier default: gpt-5.6-terra at medium reasoning effort. Haiku stayed
|
||||
// too far below the models that actually drive live sessions in the field;
|
||||
// a spec change Haiku tolerates can still confuse or be confused by the
|
||||
// frontier tier, so the harness should exercise the tier users run.
|
||||
const DEFAULT_OPENAI_MODEL = 'gpt-5.6-terra';
|
||||
const DEFAULT_OPENAI_REASONING_EFFORT = 'medium';
|
||||
const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5';
|
||||
// DeepSeek model list: https://api-docs.deepseek.com/api/list-models
|
||||
const DEFAULT_DEEPSEEK_MODEL = 'deepseek-v4-flash';
|
||||
@@ -199,6 +205,17 @@ const STEER_SYSTEM_INSTRUCTIONS = [
|
||||
export function resolveLlmAgentConfig(opts = {}, env = process.env) {
|
||||
const provider = resolveProvider(opts, env);
|
||||
|
||||
if (provider === 'openai') {
|
||||
return {
|
||||
provider,
|
||||
model: opts.model || env.IMPECCABLE_E2E_LLM_MODEL || DEFAULT_OPENAI_MODEL,
|
||||
apiKey: opts.apiKey || env.OPENAI_API_KEY,
|
||||
requiredEnv: 'OPENAI_API_KEY',
|
||||
baseURL: opts.baseURL || env.OPENAI_BASE_URL,
|
||||
reasoningEffort: opts.reasoningEffort || env.IMPECCABLE_E2E_LLM_EFFORT || DEFAULT_OPENAI_REASONING_EFFORT,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
return {
|
||||
provider,
|
||||
@@ -225,9 +242,51 @@ export function resolveLlmAgentConfig(opts = {}, env = process.env) {
|
||||
function resolveProvider(opts, env) {
|
||||
const explicit = opts.provider || env.IMPECCABLE_E2E_LLM_PROVIDER;
|
||||
if (explicit) return String(explicit).trim().toLowerCase();
|
||||
if (env.OPENAI_API_KEY) return 'openai';
|
||||
if (env.ANTHROPIC_API_KEY) return 'anthropic';
|
||||
if (env.DEEPSEEK_API_KEY) return 'deepseek';
|
||||
return 'anthropic';
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic-SDK-shaped shim over the `ai` SDK for OpenAI models, so the
|
||||
* three text-only call sites in this file stay provider-agnostic. system
|
||||
* blocks are joined (OpenAI caches long prefixes automatically; the
|
||||
* cache_control marker is Anthropic-specific), temperature is omitted
|
||||
* (reasoning models reject it), and the reasoning effort rides through
|
||||
* providerOptions.
|
||||
*/
|
||||
async function createOpenAiShim({ apiKey, baseURL, reasoningEffort, }) {
|
||||
const [{ generateText }, { createOpenAI }] = await Promise.all([
|
||||
import('ai'),
|
||||
import('@ai-sdk/openai'),
|
||||
]);
|
||||
const provider = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
return {
|
||||
messages: {
|
||||
async create({ model, system, messages, max_tokens }, { timeout } = {}) {
|
||||
const systemText = Array.isArray(system)
|
||||
? system.map((block) => block?.text || '').filter(Boolean).join('\n\n')
|
||||
: String(system || '');
|
||||
const result = await generateText({
|
||||
model: provider(model),
|
||||
system: systemText,
|
||||
messages: messages.map((m) => ({ role: m.role, content: String(m.content) })),
|
||||
maxOutputTokens: max_tokens,
|
||||
abortSignal: timeout ? AbortSignal.timeout(timeout) : undefined,
|
||||
providerOptions: { openai: { reasoningEffort } },
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: result.text }],
|
||||
usage: {
|
||||
input_tokens: result.usage?.inputTokens ?? 0,
|
||||
output_tokens: result.usage?.outputTokens ?? 0,
|
||||
cache_read_input_tokens: result.usage?.cachedInputTokens ?? 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +301,9 @@ export async function createLlmAgent(opts = {}) {
|
||||
const log = opts.log || (() => {});
|
||||
|
||||
const liveMd = opts.includeLiveSpec === false ? null : await fs.readFile(LIVE_MD_PATH, 'utf-8');
|
||||
const client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
const client = provider === 'openai'
|
||||
? await createOpenAiShim({ apiKey, baseURL, reasoningEffort: config.reasoningEffort })
|
||||
: new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
const systemBlocks = (instructions) => [
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
+131
-23
@@ -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(
|
||||
@@ -143,7 +200,10 @@ export function startDevServer(tmp, runtime) {
|
||||
const [cmd, ...args] = runtime.devCommand;
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
||||
// runtime.env lets a fixture pin framework behavior. Astro 7 needs
|
||||
// ASTRO_DEV_BACKGROUND set: it auto-detects AI-agent environments and
|
||||
// daemonizes `astro dev`, which the harness reads as a crashed server.
|
||||
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', ...(runtime.env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
@@ -228,6 +288,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 +312,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 +329,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 +339,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 +402,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 +423,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 +468,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,491 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sveltekit adapter: token revision and byte-exact removal', () => {
|
||||
let adapter;
|
||||
beforeEach(async () => {
|
||||
adapter = await import('../skill/scripts/live/sveltekit-adapter.mjs');
|
||||
});
|
||||
|
||||
const LAYOUT = `<script>
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
`;
|
||||
|
||||
it('stamps the import with a token-derived revision and swaps it on rotation', () => {
|
||||
const revA = adapter.svelteAdapterRev('token-a');
|
||||
const revB = adapter.svelteAdapterRev('token-b');
|
||||
assert.match(revA, /^[0-9a-f]{8}$/);
|
||||
assert.notEqual(revA, revB, 'a rotated token must change the module specifier');
|
||||
|
||||
const patchedA = adapter.patchSvelteLayout(LAYOUT, { rev: revA });
|
||||
assert.ok(patchedA.includes(`ImpeccableLiveRoot.svelte?impeccable-live=${revA}'`), 'import carries the revision');
|
||||
|
||||
// A re-apply after helper restart replaces the import IN PLACE: exactly
|
||||
// one import, at the new revision, same indentation. A stale specifier
|
||||
// is a cached module with a rotated-out token, which 401s on live.js.
|
||||
const patchedB = adapter.patchSvelteLayout(patchedA, { rev: revB });
|
||||
const importCount = (patchedB.match(/import ImpeccableLiveRoot/g) || []).length;
|
||||
assert.equal(importCount, 1, 'rotation must not stack imports');
|
||||
assert.ok(patchedB.includes(`?impeccable-live=${revB}'`));
|
||||
assert.ok(!patchedB.includes(`?impeccable-live=${revA}'`));
|
||||
assert.match(patchedB, /\n import ImpeccableLiveRoot/, 'replacement keeps the original indentation');
|
||||
});
|
||||
|
||||
it('removal restores the layout byte-for-byte, including neighbor indentation', () => {
|
||||
// The field failure: the old removal regex used \s* and swallowed the
|
||||
// NEXT line's indentation, de-indenting the user's stylesheet import.
|
||||
for (const rev of [null, adapter.svelteAdapterRev('some-token')]) {
|
||||
const patched = adapter.patchSvelteLayout(LAYOUT, { rev });
|
||||
assert.notEqual(patched, LAYOUT, 'patch must change the layout');
|
||||
const restored = adapter.unpatchSvelteLayout(patched);
|
||||
assert.equal(restored, LAYOUT, `removal must be byte-exact (rev=${rev})`);
|
||||
}
|
||||
});
|
||||
|
||||
it('removal of a created-from-scratch layout leaves no script husk', () => {
|
||||
const patched = adapter.patchSvelteLayout('', { rev: adapter.svelteAdapterRev('t') });
|
||||
const restored = adapter.unpatchSvelteLayout(patched);
|
||||
assert.doesNotMatch(restored, /ImpeccableLiveRoot|impeccable-live-svelte/);
|
||||
assert.doesNotMatch(restored, /<script>\s*<\/script>/);
|
||||
});
|
||||
|
||||
it('the root component embeds the tokened URL and reports load failures', () => {
|
||||
const body = adapter.buildSvelteLiveRootComponent(4321, 'tok123');
|
||||
assert.match(body, /live\.js\?token=tok123/);
|
||||
assert.match(body, /onerror/, 'a stale-token 401 must be diagnosable from the console');
|
||||
});
|
||||
});
|
||||
+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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,3 +169,66 @@ describe('live-poll stream helpers', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('just-in-time event instructions', () => {
|
||||
it('attaches situation-specific _instructions per event type', async () => {
|
||||
const { instructionsForEvent } = await import('../skill/scripts/live/instructions.mjs');
|
||||
const sp = '/scripts';
|
||||
|
||||
const steer = instructionsForEvent({ type: 'steer', id: 'ev1', message: 'x' }, { scriptsPath: sp });
|
||||
assert.match(steer, /--reply ev1 steer_done/);
|
||||
|
||||
const mountFailed = instructionsForEvent({ type: 'variant_mount_failed', id: 'ev2', variant: 2, url: 'http://x/v2.svelte', error: 'boom' }, { scriptsPath: sp });
|
||||
assert.match(mountFailed, /variant 2/);
|
||||
assert.match(mountFailed, /--reply ev2 done --file/);
|
||||
|
||||
// Svelte component generate: only svelte guidance, with concrete paths.
|
||||
const svelteGen = instructionsForEvent({
|
||||
type: 'generate', id: 'ev3', count: 3, action: 'impeccable',
|
||||
scaffold: { previewMode: 'svelte-component', componentDir: 'node_modules/.impeccable-live/ev3', file: 'node_modules/.impeccable-live/ev3/manifest.json', sourceFile: 'src/routes/+page.svelte' },
|
||||
}, { scriptsPath: sp });
|
||||
assert.match(svelteGen, /EDIT the existing stubs node_modules\/\.impeccable-live\/ev3\/v1\.svelte/);
|
||||
assert.match(svelteGen, /params\.json/);
|
||||
assert.doesNotMatch(svelteGen, /JSX|template literal/);
|
||||
assert.match(svelteGen, /--reply ev3 done --file/);
|
||||
|
||||
// Deferred source-preview generate: single-edit rule with real line numbers.
|
||||
const deferredGen = instructionsForEvent({
|
||||
type: 'generate', id: 'ev4', count: 3, action: 'bolder',
|
||||
scaffold: { sourceWritten: false, file: 'src/App.tsx', wrapperBlock: 'x', replaceStartLine: 12, replaceEndLine: 40 },
|
||||
}, { scriptsPath: sp });
|
||||
assert.match(deferredGen, /replace lines 12-40/);
|
||||
assert.match(deferredGen, /ONE edit/);
|
||||
assert.match(deferredGen, /reference\/bolder\.md/);
|
||||
assert.doesNotMatch(deferredGen, /params\.json sidecar|componentDir/);
|
||||
|
||||
// Carbonize accept: the five steps inline with the real file + complete cmd.
|
||||
const accept = instructionsForEvent({
|
||||
type: 'accept', id: 'ev5', _acceptResult: { handled: true, carbonize: true, file: 'public/index.html' }, _completionAck: { ok: true },
|
||||
}, { scriptsPath: sp });
|
||||
assert.match(accept, /public\/index\.html/);
|
||||
assert.match(accept, /live-complete\.mjs --id ev5/);
|
||||
|
||||
const mechanicalAccept = instructionsForEvent({
|
||||
type: 'accept', id: 'ev6', _acceptResult: { handled: true, carbonize: false }, _completionAck: { ok: true },
|
||||
}, { scriptsPath: sp });
|
||||
assert.match(mechanicalAccept, /nothing to clean up/i);
|
||||
|
||||
const timeout = instructionsForEvent({ type: 'timeout' }, { scriptsPath: sp });
|
||||
assert.match(timeout, /poll again/i);
|
||||
});
|
||||
|
||||
it('printPollEvent embeds _instructions in the emitted JSON', async () => {
|
||||
const { printPollEvent } = await import('../skill/scripts/live-poll.mjs');
|
||||
const lines = [];
|
||||
const orig = console.log;
|
||||
console.log = (s) => lines.push(s);
|
||||
try {
|
||||
printPollEvent({ type: 'steer', id: 'zz1', message: 'hello' });
|
||||
} finally {
|
||||
console.log = orig;
|
||||
}
|
||||
const parsed = JSON.parse(lines[0]);
|
||||
assert.match(parsed._instructions, /--reply zz1 steer_done/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 cli-render-2 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,411 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, 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 {
|
||||
consumeTargetArg,
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: multi-app pointer', () => {
|
||||
it('prefers the app whose live server is running over the last boot', async () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-multi-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
for (const name of ['siteA', 'siteB']) {
|
||||
write(repo, `${name}/vite.config.js`, 'export default {};');
|
||||
write(repo, `${name}/package.json`, `{"name":"${name}"}`);
|
||||
}
|
||||
const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'siteA/vite.config.js') }).manifest;
|
||||
const b = resolveRoots({ cwd: repo, targetPath: join(repo, 'siteB/vite.config.js') }).manifest;
|
||||
writeRootsManifest(a);
|
||||
writeRootsManifest(b); // B booted last: a naive pointer now points at B
|
||||
|
||||
// A's helper server is the one alive: an authenticated /status
|
||||
// responder on a real port, hosted in a CHILD process because the
|
||||
// probe is execFileSync and a same-process responder could never
|
||||
// accept while the event loop is blocked (production helpers are
|
||||
// always separate processes).
|
||||
const responder = spawn(process.execPath, ['-e', [
|
||||
"const s = require('node:http').createServer((q, r) => {",
|
||||
" const ok = q.url === '/status?token=t';",
|
||||
" r.writeHead(ok ? 200 : 401, { 'Content-Type': 'application/json' });",
|
||||
" r.end('{}');",
|
||||
"});",
|
||||
"s.listen(0, '127.0.0.1', () => console.log(s.address().port));",
|
||||
].join('\n')], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const livePort = await new Promise((resolve, reject) => {
|
||||
responder.stdout.once('data', (chunk) => resolve(Number(String(chunk).trim())));
|
||||
responder.once('error', reject);
|
||||
setTimeout(() => reject(new Error('responder never became ready')), 5000);
|
||||
});
|
||||
try {
|
||||
write(repo, 'siteA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: livePort, token: 't' }));
|
||||
write(repo, 'siteB/.impeccable/live/server.json', JSON.stringify({ pid: 999999999, port: 2, token: 't' }));
|
||||
|
||||
const resolved = resolveLiveRoots(repo);
|
||||
assert.equal(resolved.source, 'pointer');
|
||||
assert.equal(resolved.manifest.appRoot, join(repo, 'siteA'));
|
||||
} finally {
|
||||
responder.kill();
|
||||
}
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a legacy single-value pointer', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-legacy-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
write(repo, 'app/vite.config.js', 'export default {};');
|
||||
const m = resolveRoots({ cwd: repo, targetPath: join(repo, 'app/vite.config.js') }).manifest;
|
||||
// Write the manifest, then downgrade the pointer to the v1 shape.
|
||||
writeRootsManifest(m);
|
||||
write(repo, '.impeccable/live/app-root.json', JSON.stringify({ appRoot: join(repo, 'app') }));
|
||||
const resolved = resolveLiveRoots(repo);
|
||||
assert.equal(resolved.manifest.appRoot, join(repo, 'app'));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: stopped-session recovery', () => {
|
||||
it('prefers the app with an active durable session when no server is alive', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-stopped-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
for (const name of ['siteA', 'siteB']) {
|
||||
write(repo, `${name}/vite.config.js`, 'export default {};');
|
||||
}
|
||||
const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'siteA/vite.config.js') }).manifest;
|
||||
const b = resolveRoots({ cwd: repo, targetPath: join(repo, 'siteB/vite.config.js') }).manifest;
|
||||
writeRootsManifest(a);
|
||||
writeRootsManifest(b); // B booted last; both servers are stopped.
|
||||
|
||||
// A holds the interrupted session the user wants to recover.
|
||||
write(repo, 'siteA/.impeccable/live/sessions/ab12cd34.snapshot.json',
|
||||
JSON.stringify({ id: 'ab12cd34', phase: 'variants_ready' }));
|
||||
write(repo, 'siteB/.impeccable/live/sessions/ff00ff00.snapshot.json',
|
||||
JSON.stringify({ id: 'ff00ff00', phase: 'completed' }));
|
||||
|
||||
const resolved = resolveLiveRoots(repo);
|
||||
assert.equal(resolved.source, 'pointer');
|
||||
assert.equal(resolved.manifest.appRoot, join(repo, 'siteA'));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: helper --target', () => {
|
||||
it('enterLiveRoot honors --target and strips it from argv', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-target-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
for (const name of ['appA', 'appB']) {
|
||||
write(repo, `${name}/vite.config.js`, 'export default {};');
|
||||
}
|
||||
const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'appA/vite.config.js') }).manifest;
|
||||
const b = resolveRoots({ cwd: repo, targetPath: join(repo, 'appB/vite.config.js') }).manifest;
|
||||
writeRootsManifest(a);
|
||||
writeRootsManifest(b);
|
||||
// Both alive: pointer resolution alone is ambiguous (A? B?); --target
|
||||
// must decide, and downstream flag parsing must not see the tokens.
|
||||
write(repo, 'appA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: 1, token: 't' }));
|
||||
write(repo, 'appB/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: 2, token: 't' }));
|
||||
|
||||
const res = spawnSync(process.execPath, [
|
||||
'-e',
|
||||
`import(${JSON.stringify(ROOTS_MODULE)}).then((m) => {
|
||||
process.argv.push('--target', ${JSON.stringify(join(repo, 'appB'))});
|
||||
m.enterLiveRoot();
|
||||
console.log(JSON.stringify({ cwd: process.cwd(), argvHasTarget: process.argv.includes('--target') }));
|
||||
});`,
|
||||
], { cwd: repo, encoding: 'utf-8' });
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const out = JSON.parse(res.stdout.trim().split('\n').pop());
|
||||
assert.equal(realpathSync(out.cwd), join(repo, 'appB'));
|
||||
assert.equal(out.argvHasTarget, false);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a --target with no usable value instead of falling back to implicit selection', () => {
|
||||
for (const argv of [
|
||||
['node', 'live-complete.mjs', '--target'],
|
||||
['node', 'live-complete.mjs', '--target='],
|
||||
['node', 'live-complete.mjs', '--target', '--id'],
|
||||
]) {
|
||||
assert.throws(() => consumeTargetArg([...argv]), /--target requires a path value/);
|
||||
}
|
||||
// Well-formed values still parse and are consumed.
|
||||
const argv = ['node', 'live-complete.mjs', '--target', 'appB', '--id', 'x'];
|
||||
assert.equal(consumeTargetArg(argv), 'appB');
|
||||
assert.deepEqual(argv, ['node', 'live-complete.mjs', '--id', 'x']);
|
||||
});
|
||||
|
||||
it('enterLiveRoot exits with an error on a valueless --target rather than picking an app', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-target-bad-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
write(repo, 'appA/vite.config.js', 'export default {};');
|
||||
const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'appA/vite.config.js') }).manifest;
|
||||
writeRootsManifest(a);
|
||||
write(repo, 'appA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: 1, token: 't' }));
|
||||
|
||||
const res = spawnSync(process.execPath, [
|
||||
'-e',
|
||||
`import(${JSON.stringify(ROOTS_MODULE)}).then((m) => {
|
||||
process.argv.push('--target');
|
||||
m.enterLiveRoot();
|
||||
console.log('reached:' + process.cwd());
|
||||
});`,
|
||||
], { cwd: repo, encoding: 'utf-8' });
|
||||
assert.notEqual(res.status, 0, 'malformed --target must not proceed');
|
||||
assert.match(res.stderr, /--target requires a path value/);
|
||||
assert.doesNotMatch(res.stdout, /reached:/, 'helper body must not run');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: pid reuse', () => {
|
||||
it('does not classify a non-node process reusing the recorded pid as a live server', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-pidreuse-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
for (const name of ['appA', 'appB']) {
|
||||
write(repo, `${name}/vite.config.js`, 'export default {};');
|
||||
}
|
||||
const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'appA/vite.config.js') }).manifest;
|
||||
const b = resolveRoots({ cwd: repo, targetPath: join(repo, 'appB/vite.config.js') }).manifest;
|
||||
writeRootsManifest(a);
|
||||
writeRootsManifest(b); // B booted last.
|
||||
|
||||
// B's helper died; its pid was reused by a non-node process (launchd /
|
||||
// init: pid 1 is alive on every unix and is never a node process).
|
||||
write(repo, 'siteB-unused.txt', '');
|
||||
write(repo, 'appB/.impeccable/live/server.json', JSON.stringify({ pid: 1, port: 2, token: 't' }));
|
||||
// A holds the interrupted session the user is recovering.
|
||||
write(repo, 'appA/.impeccable/live/sessions/aa11bb22.snapshot.json',
|
||||
JSON.stringify({ id: 'aa11bb22', phase: 'variants_ready' }));
|
||||
|
||||
const resolved = resolveLiveRoots(repo);
|
||||
assert.equal(resolved.manifest.appRoot, join(repo, 'appA'));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: discovery parity', () => {
|
||||
it('discovers a live-configured static site with no bundler markers', () => {
|
||||
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-static-')));
|
||||
try {
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
write(repo, 'package.json', '{"name":"cli"}');
|
||||
write(repo, 'docs-site/.impeccable/live/config.json', '{"files":["index.html"]}');
|
||||
write(repo, 'docs-site/index.html', '<html></html>');
|
||||
const { manifest, selection } = resolveRoots({ cwd: repo });
|
||||
assert.equal(selection, undefined);
|
||||
assert.equal(manifest.appRoot, join(repo, 'docs-site'));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+346
-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');
|
||||
@@ -70,6 +74,29 @@ async function drainPolls(server) {
|
||||
} while (drained.type !== 'timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a session journal via its creating event. Progress events (checkpoints,
|
||||
* mount acks) for unknown sessions are refused with 404 unknown_session, so
|
||||
* tests that exercise them must create the session first, as the browser does.
|
||||
*/
|
||||
async function createSession(server, id, count = 3) {
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id,
|
||||
action: 'impeccable',
|
||||
count,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Ok</button>' },
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) throw new Error(`createSession(${id}) failed: HTTP ${res.status}`);
|
||||
await drainPolls(server);
|
||||
}
|
||||
|
||||
async function waitForManualActivity(server, type, { timeoutMs = 1000 } = {}) {
|
||||
const startedAt = Date.now();
|
||||
let last;
|
||||
@@ -200,6 +227,57 @@ describe('live-server integration', () => {
|
||||
await drainPolls(server);
|
||||
});
|
||||
|
||||
it('rejects progress events for sessions this store has never seen', async () => {
|
||||
await drainPolls(server);
|
||||
// A checkpoint (or any non-creating event) for an unknown id must NOT
|
||||
// materialize a session journal: that is exactly how a browser carrying
|
||||
// another project's per-origin localStorage state (two apps sharing a
|
||||
// localhost port) used to mint ghost sessions that kept reattaching.
|
||||
const foreignId = 'feedbeef';
|
||||
for (const msg of [
|
||||
{ type: 'checkpoint', id: foreignId, revision: 1, revisionDomain: 'browser', reason: 'browser_resumed_without_wrapper' },
|
||||
{ type: 'discard', id: foreignId },
|
||||
{ type: 'variant_mount_failed', id: foreignId, variant: 1, url: 'http://localhost/', error: 'mount exploded' },
|
||||
]) {
|
||||
const res = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, ...msg }),
|
||||
});
|
||||
assert.equal(res.status, 404, `${msg.type} for an unknown session must be refused`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error, 'unknown_session');
|
||||
}
|
||||
assert.equal(
|
||||
existsSync(join(getLiveSessionsDir(server.cwd), `${foreignId}.jsonl`)),
|
||||
false,
|
||||
'no ghost journal may be created for a refused session',
|
||||
);
|
||||
|
||||
// The creating event is allowed, and afterwards progress events land.
|
||||
const createRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: server.token,
|
||||
type: 'generate',
|
||||
id: foreignId,
|
||||
action: 'impeccable',
|
||||
count: 1,
|
||||
pageUrl: '/',
|
||||
element: { outerHTML: '<button>Ok</button>' },
|
||||
}),
|
||||
});
|
||||
assert.equal(createRes.status, 200);
|
||||
const checkpointRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: server.token, type: 'checkpoint', id: foreignId, revision: 2, revisionDomain: 'browser', reason: 'go' }),
|
||||
});
|
||||
assert.equal(checkpointRes.status, 200);
|
||||
await drainPolls(server);
|
||||
});
|
||||
|
||||
it('/status reports agentPolling from active poll leases', async () => {
|
||||
await drainPolls(server);
|
||||
let res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`);
|
||||
@@ -2335,6 +2413,9 @@ colors: {}
|
||||
|
||||
it('accepts checkpoint events without exposing them as agent poll work', async () => {
|
||||
await drainPolls(server);
|
||||
// Checkpoints only land on sessions the store knows, so create both first.
|
||||
await createSession(server, 'a1b2c3d7');
|
||||
await createSession(server, 'a1b2c3da');
|
||||
const partialRes = await fetch(`http://localhost:${server.port}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2448,7 +2529,7 @@ colors: {}
|
||||
token: server.token,
|
||||
type: 'agent_phase',
|
||||
id: 'a1b2c3e1',
|
||||
phase: 'first_variant_generating',
|
||||
phase: 'first_reviewable',
|
||||
owner: 'live-agent',
|
||||
}),
|
||||
});
|
||||
@@ -2456,14 +2537,34 @@ 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 () => {
|
||||
await createSession(server, 'a1b2c3de');
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
@@ -2503,6 +2604,7 @@ colors: {}
|
||||
});
|
||||
|
||||
it('streams source checkpoints so no-HMR frameworks can review variant 1', async () => {
|
||||
await createSession(server, 'a1b2c3df');
|
||||
const controller = new AbortController();
|
||||
const sseRes = await fetch(
|
||||
`http://localhost:${server.port}/events?token=${server.token}`,
|
||||
@@ -3465,4 +3567,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,375 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: durable mount failures', () => {
|
||||
it('variant_mount_failed survives a helper restart as the pending event', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-store-mountfail-'));
|
||||
try {
|
||||
const store = createLiveSessionStore({ cwd: tmp });
|
||||
store.appendEvent({ type: 'generate', id: 'mf123456', count: 3, pageUrl: '/', element: { tagName: 'h1' } });
|
||||
store.appendEvent({ type: 'agent_done', id: 'mf123456' });
|
||||
store.appendEvent({ type: 'variant_mount_failed', id: 'mf123456', variant: 2, url: 'http://x/v2.svelte', error: 'boom' });
|
||||
|
||||
// A second store instance = the restarted helper.
|
||||
const restarted = createLiveSessionStore({ cwd: tmp });
|
||||
const snapshot = restarted.getSnapshot('mf123456');
|
||||
assert.equal(snapshot.pendingEvent?.type, 'variant_mount_failed');
|
||||
assert.equal(snapshot.pendingEvent?.variant, 2);
|
||||
|
||||
// The repair reply retires it.
|
||||
restarted.appendEvent({ type: 'agent_done', id: 'mf123456', sourceEventType: 'variant_mount_failed' });
|
||||
assert.equal(restarted.getSnapshot('mf123456').pendingEvent, null);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a mount failure does not clobber a still-pending generate', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-store-mountfail2-'));
|
||||
try {
|
||||
const store = createLiveSessionStore({ cwd: tmp });
|
||||
store.appendEvent({ type: 'generate', id: 'mf223456', count: 3, pageUrl: '/', element: { tagName: 'h1' } });
|
||||
store.appendEvent({ type: 'variant_mount_failed', id: 'mf223456', variant: 1, url: 'http://x/v1.svelte', error: 'early' });
|
||||
assert.equal(store.getSnapshot('mf223456').pendingEvent?.type, 'generate');
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: directives', () => {
|
||||
it('class directives carry a className probe for live hydration', () => {
|
||||
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.deepEqual(cond.probe, { className: 'active' });
|
||||
});
|
||||
|
||||
it('style directives with dynamic values fall back to source-preview', () => {
|
||||
const res = analyzeSvelteMarkup(`<div style:opacity={fade}>x</div>`, parse);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.reason, /style:opacity/);
|
||||
});
|
||||
|
||||
it('style directives with static values stay supported', () => {
|
||||
const res = analyzeSvelteMarkup(`<div style:color="red">{note}</div>`, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: mixed and global identifiers', () => {
|
||||
it('falls back for expressions mixing loop bindings with outer names', () => {
|
||||
const src = `<ul>{#each rows as r}<li>{fmt(r.label)}</li>{/each}</ul>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.reason, /mixing loop and outer identifiers/);
|
||||
});
|
||||
|
||||
it('treats known globals as neither free nor bound', () => {
|
||||
// Global + bound: stays verbatim, no prop, no fallback.
|
||||
const okRes = analyzeSvelteMarkup(`<ul>{#each rows as r}<li>{Math.round(r.score)}</li>{/each}</ul>`, parse);
|
||||
assert.equal(okRes.ok, true, okRes.reason);
|
||||
assert.equal(okRes.contract.some((c) => c.prop === 'round'), false);
|
||||
assert.match(okRes.markupWithProps, /\{Math\.round\(r\.score\)\}/);
|
||||
|
||||
// Pure-global expression at top level: no prop minted either.
|
||||
const topRes = analyzeSvelteMarkup(`<p>{JSON.stringify(navigator.language)}</p>`, parse);
|
||||
assert.equal(topRes.ok, true, topRes.reason);
|
||||
assert.equal(topRes.contract.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: attribute slots and hydration honesty', () => {
|
||||
it('records attribute-bound values as attr slots', () => {
|
||||
const src = `<nav>{#each links as link}<a class="nav-link" href={link.href}>{link.text}</a>{/each}</nav>`;
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const item = res.contract.find((c) => c.kind === 'collection').item;
|
||||
assert.deepEqual(item.textSlots.map((s) => s.key), ['text']);
|
||||
assert.deepEqual(item.attrSlots, [{ key: 'href', expr: 'link.href', attr: 'href', tag: 'a', classes: ['nav-link'] }]);
|
||||
});
|
||||
|
||||
it('falls back for per-item expressions the shallow item cannot represent', () => {
|
||||
for (const src of [
|
||||
`<ul>{#each rows as r}<li>{r.meta.label}</li>{/each}</ul>`,
|
||||
`<ul>{#each rows as r}<li>{r.format()}</li>{/each}</ul>`,
|
||||
`<ul>{#each rows as r}<li>{r}</li>{/each}</ul>`,
|
||||
]) {
|
||||
const res = analyzeSvelteMarkup(src, parse);
|
||||
assert.equal(res.ok, false, `expected fallback for ${src}`);
|
||||
assert.match(res.reason, /cannot hydrate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('index-only expressions need no slot', () => {
|
||||
const res = analyzeSvelteMarkup(`<ul>{#each rows as r, i}<li>{i}: {r.name}</li>{/each}</ul>`, parse);
|
||||
assert.equal(res.ok, true, res.reason);
|
||||
const item = res.contract.find((c) => c.kind === 'collection').item;
|
||||
assert.deepEqual(item.textSlots.map((s) => s.key), ['name']);
|
||||
});
|
||||
|
||||
it('style directives mixing loop and outer names fall back', () => {
|
||||
const res = analyzeSvelteMarkup(`<ul>{#each rows as r}<li style:width={base + r.pct}>x</li>{/each}</ul>`, parse);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.reason, /mixing loop and outer identifiers/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: each-key restore scoping', () => {
|
||||
it('leaves a key that reads the loop binding alone when a prop shares its name', () => {
|
||||
// The corruption shape: prop `name` maps back to `user.name`, and the
|
||||
// loop context is ALSO called `name`. The key evaluates per item, so its
|
||||
// `name` is the loop binding, never the prop; restoring it used to write
|
||||
// `(user.name.id)` into the route.
|
||||
const contract = [{ prop: 'name', expr: 'user.name', kind: 'text' }];
|
||||
const markup = `<p>{name}</p>
|
||||
<ul>
|
||||
{#each people as name (name.id)}
|
||||
<li>{name.first}</li>
|
||||
{/each}
|
||||
</ul>`;
|
||||
const restored = restoreSvelteMarkup(markup, contract, parse);
|
||||
assert.equal(restored.ok, true, restored.reason);
|
||||
assert.match(restored.markup, /<p>\{user\.name\}<\/p>/, 'free usage restores to the expression');
|
||||
assert.match(restored.markup, /\(name\.id\)/, 'the key keeps the loop binding');
|
||||
assert.doesNotMatch(restored.markup, /\(user\.name\.id\)/);
|
||||
assert.match(restored.markup, /\{name\.first\}/, 'the body keeps the loop binding');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
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 {
|
||||
compileCheckVariants,
|
||||
extractMatchingSourceCss,
|
||||
removeSelectorsFromSvelteSource,
|
||||
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/);
|
||||
});
|
||||
|
||||
it('class matching is token-bounded, never substring', () => {
|
||||
// The field hazard: a falsely seeded selector becomes an accept-time
|
||||
// DELETION of a hand-written rule the pick never used.
|
||||
const route = `<style>
|
||||
.btn { color: red; }
|
||||
.btn-primary { color: blue; }
|
||||
.stage { padding: 4px; }
|
||||
.stages { display: grid; }
|
||||
</style>`;
|
||||
const { css, supersedable } = extractMatchingSourceCss(route, '<button class="btn"><span class="stage">x</span></button>');
|
||||
assert.match(css, /\.btn \{/);
|
||||
assert.match(css, /\.stage \{/);
|
||||
assert.doesNotMatch(css, /btn-primary/, '.btn must not seed .btn-primary');
|
||||
assert.doesNotMatch(css, /\.stages/, '.stage must not seed .stages');
|
||||
assert.deepEqual([...supersedable].sort(), ['.btn', '.stage']);
|
||||
});
|
||||
|
||||
it('tag rules seed the preview but are never supersedable', () => {
|
||||
const route = `<style>
|
||||
h1 { font-size: 3rem; }
|
||||
h1.hero { letter-spacing: -0.02em; }
|
||||
p { line-height: 1.6; }
|
||||
.sidebar { width: 20rem; }
|
||||
</style>`;
|
||||
const { css, supersedable } = extractMatchingSourceCss(route, '<h1 class="hero">Title</h1>');
|
||||
assert.match(css, /h1 \{ font-size/, 'bare tag rules that style the pick are seeded');
|
||||
assert.match(css, /h1\.hero/, 'class rules still seed');
|
||||
assert.doesNotMatch(css, /^p \{/m, 'unrelated tags are not seeded');
|
||||
assert.doesNotMatch(css, /\.sidebar/);
|
||||
assert.deepEqual([...supersedable], ['h1.hero'], 'only class-matched selectors may be removed on accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: preview-truth supersession (the Pitch mangle)', () => {
|
||||
const PITCH_SOURCE = `<script>
|
||||
let verdicts = [
|
||||
{ label: 'True positive', detail: 'Fix it' },
|
||||
{ label: 'False positive', detail: 'Dismiss it' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="pitch">
|
||||
<div class="decisions">
|
||||
{#each verdicts as verdict}
|
||||
<div class="cell">
|
||||
<h3>{verdict.label}</h3>
|
||||
<p>{verdict.detail}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pitch { padding: 40px; }
|
||||
.decisions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.decisions > .cell { border: 1px solid #333; }
|
||||
@media (max-width: 700px) {
|
||||
.decisions { grid-template-columns: 1fr; }
|
||||
.pitch { padding: 16px; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
it('removes seeded rules the variant did not re-declare and orders new base rules before media blocks', () => {
|
||||
const tmp2 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-pitch-mangle-')));
|
||||
try {
|
||||
mkdirSync(join(tmp2, 'node_modules'), { recursive: true });
|
||||
try {
|
||||
symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), 'dir');
|
||||
} catch {
|
||||
cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), { recursive: true });
|
||||
}
|
||||
write(tmp2, 'package.json', JSON.stringify({ name: 'app' }));
|
||||
write(tmp2, 'src/lib/Pitch.svelte', PITCH_SOURCE);
|
||||
|
||||
// Picked element: the .decisions block (lines 10-17, 1-indexed).
|
||||
const lines = PITCH_SOURCE.split('\n');
|
||||
const startLine = lines.findIndex((l) => l.includes('class="decisions"')) + 1;
|
||||
const endLine = lines.findIndex((l, i) => i >= startLine && l.trim() === '</div>' && lines[i + 1]?.includes('</section>')) + 1;
|
||||
const originalLines = lines.slice(startLine - 1, endLine);
|
||||
|
||||
const session = scaffoldSvelteComponentSession({
|
||||
id: 'pitchm1',
|
||||
count: 1,
|
||||
sourceFile: 'src/lib/Pitch.svelte',
|
||||
sourceStartLine: startLine,
|
||||
sourceEndLine: endLine,
|
||||
originalLines,
|
||||
cwd: tmp2,
|
||||
});
|
||||
assert.equal(session.fallback, undefined, session.reason);
|
||||
// Seeded selectors recorded for accept-time supersession.
|
||||
assert.equal(session.manifest.seededSelectors.includes('.decisions'), true);
|
||||
|
||||
// The agent's variant: a NEW class, no re-declaration of .decisions.
|
||||
write(tmp2, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
let { verdicts = [] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="disposition-board">
|
||||
{#each verdicts as verdict}
|
||||
<div class="lane">
|
||||
<h3>{verdict.label}</h3>
|
||||
<p>{verdict.detail}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.disposition-board { display: flex; flex-direction: column; gap: 8px; }
|
||||
.disposition-board .lane { border-left: 3px solid #7df; padding: 8px 12px; }
|
||||
@media (max-width: 700px) {
|
||||
.disposition-board .lane { padding: 6px 8px; }
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
const manifest = findSvelteComponentManifest('pitchm1', tmp2);
|
||||
const result = inlineSvelteComponentAccept(manifest, 1, null, tmp2);
|
||||
assert.equal(result.handled, true, result.error);
|
||||
const out = readFileSync(join(tmp2, 'src/lib/Pitch.svelte'), 'utf-8');
|
||||
|
||||
// The superseded grid rules are GONE: they never applied in the
|
||||
// preview the user approved, and the root keeps the old class.
|
||||
assert.doesNotMatch(out, /grid-template-columns: repeat\(3, 1fr\)/);
|
||||
assert.doesNotMatch(out, /\.decisions > \.cell/);
|
||||
assert.equal(result.css.superseded.includes('.decisions'), true);
|
||||
// The untouched sibling rule survives.
|
||||
assert.match(out, /\.pitch \{ padding: 40px; \}/);
|
||||
// Source media block survives for the surviving class...
|
||||
assert.match(out, /\.pitch \{ padding: 16px; \}/);
|
||||
// ...and no longer carries the superseded selector.
|
||||
assert.doesNotMatch(out, /\.decisions \{ grid-template-columns: 1fr; \}/);
|
||||
// New base rules sit BEFORE the source's @media block (cascade order).
|
||||
const baseIdx = out.indexOf('.disposition-board {');
|
||||
const mediaIdx = out.indexOf('@media (max-width: 700px)');
|
||||
assert.equal(baseIdx > -1 && mediaIdx > -1 && baseIdx < mediaIdx, true,
|
||||
`expected base rules before media, got base@${baseIdx} media@${mediaIdx}`);
|
||||
assert.equal(result.verify.clean, true, JSON.stringify(result.verify.findings));
|
||||
} finally {
|
||||
rmSync(tmp2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps seeded rules the variant re-declares', () => {
|
||||
const { text, removed } = removeSelectorsFromSvelteSource('<div class="a">x</div>\n<style>\n .a { color: red; }\n .b { color: blue; }\n</style>', new Set(['.b']));
|
||||
assert.match(text, /\.a \{ color: red; \}/);
|
||||
assert.doesNotMatch(text, /color: blue/);
|
||||
assert.deepEqual(removed, ['.b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: publish-time compile gate', () => {
|
||||
it('flags a variant with a duplicate top-level style block, passes after the fix', () => {
|
||||
const tmp3 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-compile-gate-')));
|
||||
try {
|
||||
mkdirSync(join(tmp3, 'node_modules'), { recursive: true });
|
||||
try {
|
||||
symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp3, 'node_modules', 'svelte'), 'dir');
|
||||
} catch {
|
||||
cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp3, 'node_modules', 'svelte'), { recursive: true });
|
||||
}
|
||||
write(tmp3, 'package.json', JSON.stringify({ name: 'app' }));
|
||||
write(tmp3, 'src/routes/+page.svelte', '<main>\n <div class="pick">hi</div>\n</main>\n');
|
||||
|
||||
const session = scaffoldSvelteComponentSession({
|
||||
id: 'gate0001',
|
||||
count: 1,
|
||||
sourceFile: 'src/routes/+page.svelte',
|
||||
sourceStartLine: 2,
|
||||
sourceEndLine: 2,
|
||||
originalLines: [' <div class="pick">hi</div>'],
|
||||
cwd: tmp3,
|
||||
});
|
||||
assert.equal(session.fallback, undefined, session.reason);
|
||||
|
||||
// The exact field failure: the agent kept the seeded block and
|
||||
// appended its own second top-level <style>.
|
||||
write(tmp3, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
let {} = $props();
|
||||
</script>
|
||||
|
||||
<div class="pick board">hi</div>
|
||||
|
||||
<style>
|
||||
.pick { color: red; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.board { margin-top: 86px; }
|
||||
</style>
|
||||
`);
|
||||
const broken = compileCheckVariants('gate0001', tmp3);
|
||||
assert.equal(broken.ok, false);
|
||||
assert.equal(broken.checked, 1);
|
||||
assert.match(broken.failures[0].message, /single top-level/);
|
||||
assert.match(broken.failures[0].file, /gate0001\/v1\.svelte/);
|
||||
assert.equal(typeof broken.failures[0].line, 'number');
|
||||
|
||||
// Merged into one block: the gate opens.
|
||||
write(tmp3, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
let {} = $props();
|
||||
</script>
|
||||
|
||||
<div class="pick board">hi</div>
|
||||
|
||||
<style>
|
||||
.pick { color: red; }
|
||||
.board { margin-top: 86px; }
|
||||
</style>
|
||||
`);
|
||||
const fixed = compileCheckVariants('gate0001', tmp3);
|
||||
assert.equal(fixed.ok, true, JSON.stringify(fixed.failures));
|
||||
} finally {
|
||||
rmSync(tmp3, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions: shared-class supersession guard', () => {
|
||||
const SHARED_SOURCE = `<script>
|
||||
let items = [{ name: 'a' }, { name: 'b' }];
|
||||
</script>
|
||||
|
||||
<section class="page">
|
||||
<div class="card intro">Intro copy stays here.</div>
|
||||
<ul class="list">
|
||||
{#each items as item}
|
||||
<li class="card">{item.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { padding: 24px; }
|
||||
.card { border: 1px solid #999; border-radius: 8px; }
|
||||
.list { display: grid; gap: 8px; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
it('keeps a superseded selector whose class is still used outside the replaced region', () => {
|
||||
const tmp4 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-shared-class-')));
|
||||
try {
|
||||
mkdirSync(join(tmp4, 'node_modules'), { recursive: true });
|
||||
try {
|
||||
symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp4, 'node_modules', 'svelte'), 'dir');
|
||||
} catch {
|
||||
cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp4, 'node_modules', 'svelte'), { recursive: true });
|
||||
}
|
||||
write(tmp4, 'package.json', JSON.stringify({ name: 'app' }));
|
||||
write(tmp4, 'src/lib/Shared.svelte', SHARED_SOURCE);
|
||||
|
||||
// Pick the <ul class="list"> block. Its items use .card, and so does
|
||||
// the intro div OUTSIDE the pick.
|
||||
const lines = SHARED_SOURCE.split('\n');
|
||||
const startLine = lines.findIndex((l) => l.includes('class="list"')) + 1;
|
||||
const endLine = lines.findIndex((l) => l.trim() === '</ul>') + 1;
|
||||
const originalLines = lines.slice(startLine - 1, endLine);
|
||||
|
||||
const session = scaffoldSvelteComponentSession({
|
||||
id: 'shared01',
|
||||
count: 1,
|
||||
sourceFile: 'src/lib/Shared.svelte',
|
||||
sourceStartLine: startLine,
|
||||
sourceEndLine: endLine,
|
||||
originalLines,
|
||||
cwd: tmp4,
|
||||
});
|
||||
assert.equal(session.fallback, undefined, session.reason);
|
||||
assert.equal(session.manifest.seededSelectors.includes('.card'), true, 'the pick uses .card, so it seeds');
|
||||
|
||||
// The variant re-declares .list but NOT .card.
|
||||
write(tmp4, join(session.componentDir, 'v1.svelte'), `<script>
|
||||
let { items = [] } = $props();
|
||||
</script>
|
||||
|
||||
<ul class="list">
|
||||
{#each items as item}
|
||||
<li class="card">{item.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.list { display: flex; flex-direction: column; gap: 12px; }
|
||||
</style>
|
||||
`);
|
||||
const manifest = findSvelteComponentManifest('shared01', tmp4);
|
||||
const result = inlineSvelteComponentAccept(manifest, 1, null, tmp4);
|
||||
assert.equal(result.handled, true, result.error);
|
||||
const out = readFileSync(join(tmp4, 'src/lib/Shared.svelte'), 'utf-8');
|
||||
|
||||
// .card is shared with the intro div outside the replaced region:
|
||||
// removing it would strip styling from markup this accept never
|
||||
// touched, so it must survive despite not being re-declared.
|
||||
assert.match(out, /\.card \{ border: 1px solid #999; border-radius: 8px; \}/);
|
||||
assert.equal(result.css.superseded.includes('.card'), false);
|
||||
// The re-declared .list took the variant's shape.
|
||||
assert.match(out, /\.list \{ display: flex/);
|
||||
} finally {
|
||||
rmSync(tmp4, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('live target-aware monorepo context', () => {
|
||||
|
||||
const poll = runNode(LIVE_POLL_SCRIPT, ['--timeout=50'], payload.projectRoot);
|
||||
assert.equal(poll.status, 0, `stdout:\n${poll.stdout}\nstderr:\n${poll.stderr}`);
|
||||
assert.deepEqual(JSON.parse(poll.stdout), { type: 'timeout' });
|
||||
assert.deepEqual(JSON.parse(poll.stdout), { type: 'timeout', _instructions: 'No event arrived; poll again immediately.' });
|
||||
|
||||
const stop = runNode(LIVE_SERVER_SCRIPT, ['stop', '--keep-inject'], payload.projectRoot);
|
||||
assert.equal(stop.status, 0, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`);
|
||||
|
||||
Reference in New Issue
Block a user