diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c7d4c26..ff7e534e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] workflow_dispatch: + # Nightly full live-e2e matrix. The smoke groups already gate every PR; the + # full sweep is too slow for that, so it runs once a day against main. + schedule: + - cron: '0 7 * * *' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -157,14 +161,16 @@ jobs: name: live-e2e smoke (${{ matrix.group }}) runs-on: ubuntu-latest needs: changes - if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' + if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' timeout-minutes: 15 strategy: fail-fast: true matrix: include: - group: platform - fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit + - group: svelte + fixtures: vite8-sveltekit-stateful - group: react fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain steps: @@ -225,14 +231,16 @@ jobs: name: live-e2e full (${{ matrix.group }}) runs-on: ubuntu-latest needs: changes - if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch' + if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') timeout-minutes: 25 strategy: fail-fast: true matrix: include: - group: platform - fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router + - group: svelte + fixtures: vite8-sveltekit,vite8-sveltekit-stateful - group: react-a fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion - group: react-b diff --git a/.impeccable/config.json b/.impeccable/config.json index 84e557937..95ab71955 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -3,6 +3,7 @@ "ignoreRules": [], "ignoreFiles": [ "tests/fixtures/**", + "tests/framework-fixtures/**", "tests/detect-antipatterns.test.js" ], "ignoreValues": [ diff --git a/CLAUDE.md b/CLAUDE.md index ba558b181..db9bb2345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,12 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de **Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`. +Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`): + +- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest. +- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r/`, 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. diff --git a/bun.lock b/bun.lock index b6b622322..4e2861ecb 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "ai": "^7.0.14", "archiver": "^8.0.0", "playwright": "^1.59.1", + "svelte": "^5", "zod": "^4.3.6", }, "optionalDependencies": { @@ -74,6 +75,16 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], @@ -82,6 +93,12 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], @@ -90,6 +107,8 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "ai": ["ai@7.0.31", "", { "dependencies": { "@ai-sdk/gateway": "4.0.23", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -102,8 +121,12 @@ "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -142,6 +165,8 @@ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -172,6 +197,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="], + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], @@ -202,6 +229,10 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -270,6 +301,8 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -290,6 +323,10 @@ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -398,6 +435,8 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="], + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], @@ -434,6 +473,8 @@ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/docs/LIVE-REWRITE-PLAN.md b/docs/LIVE-REWRITE-PLAN.md new file mode 100644 index 000000000..431ec046f --- /dev/null +++ b/docs/LIVE-REWRITE-PLAN.md @@ -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 `
  • `. 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 `/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 ``. 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 `\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 `` 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 `` or - // `` 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. - * ``), 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\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 ``, -// 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 = /]*?)\/?>/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 - // `` 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'; diff --git a/skill/scripts/live-insert.mjs b/skill/scripts/live-insert.mjs index b4c17cca1..8d5829ea4 100644 --- a/skill/scripts/live-insert.mjs +++ b/skill/scripts/live-insert.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(); } diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs index 19a8b0f9b..f87a4de45 100644 --- a/skill/scripts/live-poll.mjs +++ b/skill/scripts/live-poll.mjs @@ -14,6 +14,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; // Absolute path to a sibling script in this skill's scripts dir, so runtime // error hints print a directly-runnable command instead of a placeholder. @@ -412,5 +413,6 @@ export function normalizePollTypes(value) { // Auto-execute when run directly const _running = process.argv[1]; if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + enterLiveRoot(); pollCli(); } diff --git a/skill/scripts/live-resume.mjs b/skill/scripts/live-resume.mjs index 74284d48a..04306ff0c 100644 --- a/skill/scripts/live-resume.mjs +++ b/skill/scripts/live-resume.mjs @@ -4,6 +4,7 @@ */ import { createLiveSessionStore } from './live/session-store.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; @@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) { return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort(); } +/** + * The browser's render truth, folded into a small block the agent reads before + * it decides what to do. `arrivedVariants` only says the agent published; + * `renderState` says whether any of it reached a screen. + */ +export function renderSummary(snapshot = {}) { + return { + renderState: snapshot.renderState ?? null, + mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [], + mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [], + }; +} + +export function mountFailureAction(snapshot = {}) { + const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : []; + const latest = failures[failures.length - 1]; + if (!latest) return null; + const where = latest.url ? ` from ${latest.url}` : ''; + const why = latest.error ? ` (${latest.error})` : ''; + return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file 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(); } diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 113ad01b3..14a207508 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs'; import { validateEvent } from './live/event-validation.mjs'; import { selectAvailablePendingEvent } from './live/poll-lanes.mjs'; import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; -import { LIVE_COMMANDS } from './live/vocabulary.mjs'; +import { + LIVE_COMMANDS, + VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST, +} from './live/vocabulary.mjs'; import { getDesignSidecarPath, getLiveDir, @@ -51,24 +54,46 @@ import { } from './live/manual-apply.mjs'; import { applyDeferredSvelteComponentAccepts, + bumpSvelteComponentPreviewRevision, removeAllSvelteComponentSessions, + sweepInactiveSvelteComponentSessions, } from './live/svelte-component.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated -// DESIGN sidecar is project-local at .impeccable/design.json, with legacy -// DESIGN.json fallback for existing projects. -const PROJECT_CONTEXT = loadContext(process.cwd()); -const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; -const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath - ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) - : null; +// Anchor the whole process on the live roots manifest before anything derives +// a path from cwd. A server started from the wrong directory re-roots itself +// onto the appRoot the boot decided on instead of minting a second project. +const LIVE_ROOTS = enterLiveRoot(process.cwd()); + +// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server +// that outlives an `impeccable document` run (or a context file created after +// boot) reports current truth instead of a boot-time snapshot. The roots +// manifest wins when the ambient resolution misses (nested app inheriting +// repo-level context files). +function resolveProjectContext() { + const ctx = loadContext(process.cwd()); + const designPath = ctx.designPath + ? path.resolve(process.cwd(), ctx.designPath) + : (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null); + const hasProduct = ctx.hasProduct + || !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath)); + return { + ...ctx, + hasProduct, + hasDesign: !!designPath, + resolvedDesignPath: designPath, + contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(), + designContextDir: ctx.designContextDir + || (designPath ? path.dirname(designPath) : null), + }; +} const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s // The browser checkpoints for several unrelated reasons (see checkpointPayload // in live-browser.js). Only these two report that variant availability changed, // and only they may drive variant_progress / the *_reviewable phases. -const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']); +const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST); // --------------------------------------------------------------------------- // Port detection @@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) { generationCompletedAt: snapshot.generationCompletedAt ?? null, generationCanceled: snapshot.generationCanceled === true, cancelReason: snapshot.cancelReason ?? null, + // Render truth, so a browser with no localStorage can rehydrate to the + // same comparison the server already knows about. + mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [], + mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [], + renderState: snapshot.renderState ?? null, }; } @@ -618,7 +648,7 @@ function hasProjectContext() { // PRODUCT.md carries brand voice / anti-references — that's what determines // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate // concern, surfaced by the design panel's own empty state. - return !!PROJECT_CONTEXT.hasProduct; + return !!resolveProjectContext().hasProduct; } function statOrNull(filePath) { @@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = DESIGN_MD_PATH; - const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const projectContext = resolveProjectContext(); + const mdPath = projectContext.resolvedDesignPath; + const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) { if (msg.type === 'exit') { cleanupSvelteComponentSessionsBeforeExit(); } - if (msg.type !== 'checkpoint') { + // `variant_mounted` is the happy path: it is journaled above so the + // snapshot carries render truth, but there is nothing for the agent to + // do about it, so it stays out of the poll queue and off the SSE bus. + // `variant_mount_failed` is the opposite: the agent published something + // the browser could not render, and only the agent can fix it, so it + // goes to the queue as a first-class event. + if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') { enqueueEvent(msg); } res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) { const base = { file: normalized }; const metadataFile = normalized; if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base; - if (!metadataFile.includes('node_modules/.impeccable-live/') + if (!metadataFile.includes('.impeccable/live/previews/') + && !metadataFile.includes('node_modules/.impeccable-live/') && !metadataFile.includes('src/lib/impeccable/') && !metadataFile.includes('/.impeccable-live/')) return base; @@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) { // `agent_done` can be the automatic acknowledgement for a carbonize Accept. // New pollers send sourceEventType explicitly; default to generate only for // older callers so a late worker cannot acknowledge a queued Accept. - if (msg.type === 'agent_done' || msg.type === 'done') return 'generate'; + if (msg.type === 'agent_done' || msg.type === 'done') { + // A `done` reply to a mount failure is the republish that unblocks the + // browser. Without this the ack would look for a `generate` that was + // already retired, the mount-failure event would stay queued, and the next + // poll would hand the same failure back to the agent forever. + if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed'; + return 'generate'; + } // `error` is reference/live.md's documented failure reply, and parseReplyArgs // never sets sourceEventType on it (the poller is a fresh process that cannot // know what it leased). Returning undefined here makes acknowledgePendingEvent @@ -1264,6 +1309,15 @@ function handlePollPost(req, res) { return; } const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); + // A publish (done reply carrying a component manifest) snapshots the + // variant files into a fresh revision dir before the browser is told: + // the import path changes every publish, so no transform cache can pin a + // stale compile of a republished module (node_modules is unwatched). + if (replyFileMeta.previewMode === 'svelte-component' + && msg.id + && (msg.type === 'done' || !msg.type)) { + try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ } + } if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() { } } +/** + * A previous run that died without its shutdown hook leaves preview component + * dirs behind. Drop the ones whose session the store no longer considers + * active; anything still active is mid-generation and must survive a restart. + */ +function sweepOrphanSvelteComponentSessionsOnStartup() { + try { + const activeIds = (state.sessionStore?.listActiveSessions() || []) + .map((snapshot) => snapshot?.id) + .filter(Boolean); + const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd()); + if (result.removed.length > 0 || result.removedRoot) { + console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] Svelte component session sweep failed:', err.message); + } +} + +// Accept receipts are a short-lived idempotency record for a single accept. +// Nothing reads one after the session that wrote it is gone, so they only need +// to outlive a crash-and-retry window. +const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; + +function sweepStaleAcceptReceiptsOnStartup() { + try { + const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts'); + if (!fs.existsSync(dir)) return; + const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS; + let removed = 0; + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue; + const file = path.join(dir, name); + try { + if (fs.statSync(file).mtimeMs >= cutoff) continue; + fs.rmSync(file, { force: true }); + removed++; + } catch { /* non-fatal */ } + } + if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`); + } catch (err) { + console.warn('[impeccable] accept receipt retention sweep failed:', err.message); + } +} + function applyLegacyDeferredAcceptsOnStartup() { try { const result = applyDeferredSvelteComponentAccepts(process.cwd()); @@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({ reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); applyLegacyDeferredAcceptsOnStartup(); +sweepOrphanSvelteComponentSessionsOnStartup(); +sweepStaleAcceptReceiptsOnStartup(); restorePendingEventsFromStore(); manualApply.pruneStaleEvidence(); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/skill/scripts/live-status.mjs b/skill/scripts/live-status.mjs index dc98b7386..6ed2b890a 100644 --- a/skill/scripts/live-status.mjs +++ b/skill/scripts/live-status.mjs @@ -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 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(); } diff --git a/skill/scripts/live-wrap.mjs b/skill/scripts/live-wrap.mjs index 090ad30e9..55e83536f 100644 --- a/skill/scripts/live-wrap.mjs +++ b/skill/scripts/live-wrap.mjs @@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; import { findSourceFile } from './live/source-search.mjs'; +import { resolveSourceTraits } from './live/frameworks/index.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; export async function wrapCli() { const args = process.argv.slice(2); @@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`); .join('\n'); const originalIndented = reindentOriginal(' '); const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); - const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile); - const useFrameworkComponent = useSvelteComponent; + // The registry says which files get component preview; the svelte-component + // module keeps the env escape hatch that turns it off. + const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component' + && shouldUseSvelteComponentInjection(targetFile); // Wrapper attributes differ by syntax. HTML allows plain string attrs; // JSX requires object-literal style and parses string attrs as HTML (which @@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`); let svelteSession = null; let deferredWrapper = null; + let sveltePreviewFallback = null; if (useSvelteComponent) { // Svelte/SvelteKit resets component-local state on markup HMR updates. // Keep generation source-neutral: agents write real variant components // under the generated componentDir, the browser mounts them into the live // DOM, and live-accept.mjs inlines the accepted variant back into the route. - svelteSession = scaffoldSvelteComponentSession({ + // + // The scaffold is AST-based and refuses markup a detached preview cannot + // support (component tags, bind:/use:, await blocks, bound nested each). + // Refusal falls back to the plain source-preview wrapper below: an + // HMR-resetting but CORRECT preview beats a detached wrong one. + const scaffolded = scaffoldSvelteComponentSession({ id, count, sourceFile: relTargetFile, @@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`); originalLines, cwd: process.cwd(), }); - outputFile = path.resolve(process.cwd(), svelteSession.manifestFile); - outputStartLine = 1; - outputEndLine = 1; - insertLine = 1; + if (scaffolded && scaffolded.fallback === 'source-preview') { + sveltePreviewFallback = scaffolded.reason || 'unsupported markup'; + } else { + svelteSession = scaffolded; + outputFile = path.resolve(process.cwd(), svelteSession.manifestFile); + outputStartLine = 1; + outputEndLine = 1; + insertLine = 1; + } + } + if (svelteSession) { + // component preview: outputs already set above } else if (deferSourceWrite) { // Deferred source write: compute the scaffold text but leave source // untouched. The agent replaces the picked element's source range with @@ -396,15 +414,19 @@ The agent should insert variant HTML at insertLine.`); const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/'); - const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null; + const componentPreviewActive = !!svelteSession; + const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null; const componentSession = svelteSession; - const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined; + const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined; const previewMode = componentPreviewMode; console.log(JSON.stringify({ file: outputRelFile, - sourceFile: useFrameworkComponent ? relTargetFile : undefined, + sourceFile: componentPreviewActive ? relTargetFile : undefined, previewMode, + previewFallback: sveltePreviewFallback + ? { from: 'svelte-component', reason: sveltePreviewFallback } + : undefined, // Deferred source write: the wrapper is NOT yet in source. The agent // replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants // spliced at the "insert below this line" marker) in one atomic edit. @@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`); replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined, componentDir: componentSession?.componentDir, propContract: componentSession?.propContract, - sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined, - sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined, + sourceStartLine: componentPreviewActive ? startLine + 1 : undefined, + sourceEndLine: componentPreviewActive ? endLine + 1 : undefined, startLine: outputStartLine, // 1-indexed for the agent // wrapperLines is an array but one element (the original-content slot) // is a `\n`-joined multi-line string, so the actual file-row count is @@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`); insertLine, // 1-indexed: where variants go commentSyntax: commentSyntax, styleMode: componentPreviewMode || styleMode.mode, - styleTag: useFrameworkComponent ? null : styleMode.styleTag, - cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count), + styleTag: componentPreviewActive ? null : styleMode.styleTag, + cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count), cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count), originalLineCount: originalLines.length, })); @@ -630,27 +652,22 @@ function attrEscapeDouble(str) { .replace(/>/g, '>'); } +/** + * Comment syntax, style mode, and preview strategy all come from the framework + * registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX + * comments, `.astro` needs global-prefixed preview CSS because Astro scopes + * component styles away from the generated wrappers, `.svelte` gets component + * preview. See live/frameworks/index.mjs for why extension and not project. + */ function detectCommentSyntax(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.jsx' || ext === '.tsx') { - return { open: '{/*', close: '*/}' }; - } - // HTML, Vue, Svelte, Astro all use HTML comments - return { open: '' }; + return resolveSourceTraits(filePath).commentSyntax === 'jsx' + ? { open: '{/*', close: '*/}' } + : { open: '' }; } function detectStyleMode(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.astro') { - return { - mode: 'astro-global-prefixed', - styleTag: '\n`; } +/** + * Scaffold a component-preview session. The scaffold is AST-based: the app's + * own svelte compiler parses the selected markup, control-flow blocks are + * preserved (an each collection crosses the prop contract as ONE structured + * prop, its loop body verbatim), and constructs a detached preview cannot + * support return `{ fallback: 'source-preview', reason }` so the caller keeps + * the markup inside the route file instead of shipping a wrong preview. + */ export function scaffoldSvelteComponentSession({ id, count, @@ -145,17 +191,31 @@ export function scaffoldSvelteComponentSession({ originalLines, cwd = process.cwd(), }) { + const originalMarkup = originalLines.join('\n'); + + const compiler = loadSvelteCompiler(cwd); + if (!compiler) { + return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' }; + } + const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse); + if (!analysis.ok) { + return { fallback: 'source-preview', reason: analysis.reason }; + } + ensureRuntimeHelper(cwd); const dir = componentSessionDir(id, cwd); fs.mkdirSync(dir, { recursive: true }); - const originalMarkup = originalLines.join('\n'); - const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); - const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + const contract = analysis.contract; + const seededCss = extractMatchingSourceCss( + safeReadSource(path.resolve(cwd, sourceFile)), + originalMarkup, + ); const manifest = { id, previewMode: 'svelte-component', + contractVersion: 2, sourceFile: sourceFile.split(path.sep).join('/'), sourceStartLine, sourceEndLine, @@ -163,7 +223,14 @@ export function scaffoldSvelteComponentSession({ propContract: contract, originalMarkup, componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + // Absolute paths let the browser fall back to /@fs/ imports when the dev + // server's base or root makes root-relative URLs miss, and probe whether + // the preview tree is reachable at all before blaming a variant. + componentDirAbs: dir.split(path.sep).join('/'), runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), + probeModule: `/${SVELTE_PROBE_FILE}`, + probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), }; fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); @@ -171,7 +238,7 @@ export function scaffoldSvelteComponentSession({ for (let n = 1; n <= count; n++) { const variantFile = path.join(dir, `v${n}.svelte`); if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8'); } } @@ -183,6 +250,59 @@ export function scaffoldSvelteComponentSession({ }; } +function safeReadSource(filePath) { + try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } +} + +/** + * Seed variant stubs with the source component's rules that already style the + * selected markup, so variants start from the real cascade (a detached + * preview inherits none of the route's compile-scoped CSS) instead of + * reimplementing it blind. + */ +export function extractMatchingSourceCss(routeSource, originalMarkup) { + const styleMatch = String(routeSource || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return ''; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return ''; + + const selectorMatches = (prelude) => splitSelectorList(prelude).some((selector) => { + for (const cls of classNames) if (selector.includes(`.${cls}`)) return true; + return false; + }); + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && selectorMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return serializeNodes(pick(parseStylesheet(styleMatch[1]))); +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + const css = seededCss + ? `\n\n` + : `\n\n`; + return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`; +} + export function scaffoldSvelteComponentInsertSession({ id, count, @@ -213,7 +333,11 @@ export function scaffoldSvelteComponentInsertSession({ count, propContract: [], componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + componentDirAbs: dir.split(path.sep).join('/'), runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), + probeModule: `/${SVELTE_PROBE_FILE}`, + probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), }; fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); @@ -238,16 +362,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) { if (fs.existsSync(direct)) { return readManifest(direct); } - const root = path.join(cwd, SVELTE_COMPONENT_ROOT); - if (!fs.existsSync(root)) return null; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const candidate = path.join(root, entry.name, 'manifest.json'); - if (!fs.existsSync(candidate)) continue; - try { - const manifest = readManifest(candidate); - if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; - } catch { /* skip */ } + // Legacy location: a session scaffolded by an older version can still be + // accepted after an upgrade. + const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json'); + if (fs.existsSync(legacyDirect)) { + return readManifest(legacyDirect); + } + for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { + const root = path.join(cwd, rootRel); + if (!fs.existsSync(root)) continue; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } } return null; } @@ -451,35 +583,6 @@ function rewriteParamSelectors(selector, paramValues) { return { keep, selector: next }; } -function splitSelectorList(prelude) { - const selectors = []; - let start = 0; - let bracket = 0; - let paren = 0; - let quote = null; - for (let i = 0; i < prelude.length; i++) { - const ch = prelude[i]; - if (quote) { - if (ch === '\\') i++; - else if (ch === quote) quote = null; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '[') bracket++; - else if (ch === ']') bracket = Math.max(0, bracket - 1); - else if (ch === '(') paren++; - else if (ch === ')') paren = Math.max(0, paren - 1); - else if (ch === ',' && bracket === 0 && paren === 0) { - selectors.push(prelude.slice(start, i)); - start = i + 1; - } - } - selectors.push(prelude.slice(start)); - return selectors; -} function selectorHasVariant(selector, variantNum) { return variantSelectorRegex(variantNum).test(selector); @@ -527,10 +630,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = const rootTag = matchOpeningTag(markup)?.tag || 'div'; const contract = manifest.propContract || []; + const compiler = loadSvelteCompiler(cwd); const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); - const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) - .split('\n') - .map((line) => line.trimEnd()); + + // Restore props back to route expressions. Contract v2 restores through the + // AST so a prop used without braces (each headers, attribute positions) + // still maps back to its original expression; v1 falls back to the textual + // placeholder swap. + let restoredText; + if (Number(manifest.contractVersion) === 2 && compiler) { + const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse); + if (!restored.ok) { + return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase }; + } + restoredText = restored.markup; + } else { + restoredText = substitutePropsWithExprs(mergedMarkup, contract); + } + const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd()); const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); const sourceLines = sourceContent.split('\n'); @@ -541,10 +658,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = } const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = restoredMarkup.map((line) => { - if (line.trim() === '') return ''; - return indent + line.trimStart(); - }); + const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); let newLines = [ ...sourceLines.slice(0, start), @@ -552,25 +666,145 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = ...sourceLines.slice(end + 1), ]; - const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); - const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); - if (bakedCss.length > 0) { - newLines = appendCssToSvelteStyle(newLines, bakedCss); + // Selectors that were already unused before this accept are the user's + // pre-existing code; the pruning pass must not touch them. + const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set(); + + // Bake params (declared kinds from params.json drive branch pruning), then + // MERGE into the component's existing style block: matching selectors are + // replaced, new ones appended. Appending alone is how superseded rules used + // to survive their own replacement. + const declaredParams = readDeclaredParams(manifest, variantNum, cwd); + let variantCss = cssLines.join('\n'); + if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { + // Defensive: strip preview-wrapper selectors that authoring rules forbid + // on this path but an off-spec agent may still emit. + variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); + } + const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); + const cssStats = { replaced: 0, appended: 0, pruned: [] }; + if (bakedCss.trim()) { + const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); + newLines = merged.text.split('\n'); + cssStats.replaced = merged.replaced; + cssStats.appended = merged.appended; + } + + let finalText = newLines.join('\n'); + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune deliberately removed it. This turns any + // parser or reconciler defect into a loud refusal instead of silent damage + // to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, cssStats.pruned); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { + const before = collectAllSelectors(styleBlockText(beforeSource)); + const after = collectAllSelectors(styleBlockText(afterSource)); + const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s))); + const lost = []; + for (const selector of before) { + if (!after.has(selector) && !pruned.has(selector)) lost.push(selector); + } + return lost; +} + +function readDeclaredParams(manifest, variantNum, cwd) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8')); + const list = raw?.[String(variantNum)]; + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +/** + * Merge CSS into a svelte component's top-level style block (created when + * absent), replacing rules whose selectors match and appending the rest. + */ +export function mergeCssIntoSvelteSource(sourceText, incomingCss) { + const text = String(sourceText || ''); + const styleRe = /]*>([\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\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`; + return { + text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length), + replaced, + appended, + }; +} + +function indentCssBlock(css) { + return String(css || '') + .split('\n') + .map((line) => (line.trim() === '' ? '' : ' ' + line)) + .join('\n'); +} + function inlineSvelteComponentInsertAccept({ manifest, markup, @@ -601,10 +835,7 @@ function inlineSvelteComponentInsertAccept({ const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = restoredMarkup.map((line) => { - if (line.trim() === '') return ''; - return indent + line.trimStart(); - }); + const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); let newLines = [ ...sourceLines.slice(0, insertIndex), @@ -612,10 +843,15 @@ function inlineSvelteComponentInsertAccept({ ...sourceLines.slice(insertIndex), ]; - const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); - const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); - if (bakedCss.length > 0) { - newLines = appendCssToSvelteStyle(newLines, bakedCss); + let variantCss = cssLines.join('\n'); + if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { + variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); + } + const declaredParams = readDeclaredParams(manifest, variantNum, cwd); + const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); + if (bakedCss.trim()) { + const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); + newLines = merged.text.split('\n'); } try { @@ -625,8 +861,10 @@ function inlineSvelteComponentInsertAccept({ } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(newLines.join('\n')); return { handled: true, + verify, ...resultBase, }; } @@ -729,18 +967,124 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) { } catch { /* non-fatal */ } } +/** + * Snapshot the agent-authored variant files into a fresh revision directory + * and stamp the manifest. Called by the server on every publish (`done` + * reply) for a component session; the browser imports from the revision dir, + * so the dev server can never serve a stale compile of a republished file. + */ +export function bumpSvelteComponentPreviewRevision(id, cwd = process.cwd()) { + const manifest = findSvelteComponentManifest(id, cwd); + if (!manifest || !manifest.manifestPath) return null; + const sessionDir = path.dirname(manifest.manifestPath); + const revision = Number(manifest.revision || 0) + 1; + const revDirName = `r${revision}`; + const revDir = path.join(sessionDir, revDirName); + try { + fs.mkdirSync(revDir, { recursive: true }); + let entries = []; + try { entries = fs.readdirSync(sessionDir, { withFileTypes: true }); } catch { /* empty */ } + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === 'manifest.json') continue; + fs.copyFileSync(path.join(sessionDir, entry.name), path.join(revDir, entry.name)); + } + // Previous revision dirs are dead the moment a new one exists. + for (const entry of entries) { + if (entry.isDirectory() && /^r\d+$/.test(entry.name) && entry.name !== revDirName) { + try { fs.rmSync(path.join(sessionDir, entry.name), { recursive: true, force: true }); } catch { /* non-fatal */ } + } + } + const relSessionDir = path.relative(cwd, sessionDir).split(path.sep).join('/'); + const updated = { + ...manifest, + revision, + revisionDir: `${relSessionDir}/${revDirName}`, + revisionDirAbs: revDir.split(path.sep).join('/'), + }; + delete updated.manifestPath; + fs.writeFileSync(manifest.manifestPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8'); + return { revision, revisionDir: updated.revisionDir }; + } catch { + return null; + } +} + +/** + * Stop-path sweep. The whole `node_modules/.impeccable-live` tree is + * impeccable-owned and gitignored, so once no session should survive there is + * nothing left worth keeping: the per-session dirs, the generated + * `__runtime.js`, and the parent directory all go. The old per-entry loop + * skipped `__*` entries and the parent, which left the runtime shim and an + * empty directory in every project that ever ran live mode once. + */ export function removeAllSvelteComponentSessions(cwd = process.cwd()) { - const root = path.join(cwd, SVELTE_COMPONENT_ROOT); - if (!fs.existsSync(root)) return; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('__')) continue; + for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { + const root = path.join(cwd, rootRel); + if (!fs.existsSync(root)) continue; try { - fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); } catch { /* non-fatal */ } } } +/** + * Boot-path sweep. A restart must not delete the tree wholesale: sessions + * recorded in the session store may still be mid-generation. Remove only the + * session dirs whose id has no active snapshot, then drop `__runtime.js` and + * the parent directory when nothing is left to serve. + * + * @param {Iterable} 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'); diff --git a/skill/scripts/live/tanstack-adapter.mjs b/skill/scripts/live/tanstack-adapter.mjs index c682914b1..4a1c81a97 100644 --- a/skill/scripts/live/tanstack-adapter.mjs +++ b/skill/scripts/live/tanstack-adapter.mjs @@ -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 */}'; diff --git a/skill/scripts/live/vocabulary.mjs b/skill/scripts/live/vocabulary.mjs index 5c7b0b713..ce4e0927a 100644 --- a/skill/scripts/live/vocabulary.mjs +++ b/skill/scripts/live/vocabulary.mjs @@ -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', +]); diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs index 15be7cf41..112739427 100644 --- a/tests/framework-fixtures.test.mjs +++ b/tests/framework-fixtures.test.mjs @@ -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 }); diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md index bbbaa8c01..a4f871ebb 100644 --- a/tests/framework-fixtures/README.md +++ b/tests/framework-fixtures/README.md @@ -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": "]*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 `//`, 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 `

    `), 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/v` 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=[,]` 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. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md new file mode 100644 index 000000000..d3e9e42e4 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md @@ -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. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md new file mode 100644 index 000000000..86564cc0b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md @@ -0,0 +1,24 @@ +# Product + + + +## 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. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/package.json b/tests/framework-fixtures/monorepo-nested-vite/files/package.json new file mode 100644 index 000000000..bd512177b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/package.json @@ -0,0 +1,4 @@ +{ + "name": "fake-cli-tool", + "private": true +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html new file mode 100644 index 000000000..c3fcf9c3a --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html @@ -0,0 +1,11 @@ + + + + + Nested Website Fixture + + +
    + + + diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json b/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json new file mode 100644 index 000000000..91e309b9b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json @@ -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" + } +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx new file mode 100644 index 000000000..779f5cd2c --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx @@ -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 ( +
    +
    +

    Nested Website Fixture

    +

    The served app lives in website/, one level below the repo root.

    +
    +
    +
    One
    +
    Two
    +
    +
    + {foundationCards.map((card) => ( +
    + {card.label} +

    {card.detail}

    +
    + ))} +
    +
    + Learn more + Learn more +
    +
    + ); +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx new file mode 100644 index 000000000..f2baba283 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx @@ -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( + + + , +); diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css new file mode 100644 index 000000000..10606269d --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css @@ -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; } diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js b/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js new file mode 100644 index 000000000..dd5cfa6f0 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js @@ -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, + }, +}); diff --git a/tests/framework-fixtures/monorepo-nested-vite/fixture.json b/tests/framework-fixtures/monorepo-nested-vite/fixture.json new file mode 100644 index 000000000..424028816 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/fixture.json @@ -0,0 +1,42 @@ +{ + "name": "Repo root + nested Vite app in website/", + "config": { + "files": ["**/index.html"], + "insertBefore": "", + "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" + } + } +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt b/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json diff --git a/tests/framework-fixtures/vite8-react-mapped-list/fixture.json b/tests/framework-fixtures/vite8-react-mapped-list/fixture.json index d9e1a1911..61b415db9 100644 --- a/tests/framework-fixtures/vite8-react-mapped-list/fixture.json +++ b/tests/framework-fixtures/vite8-react-mapped-list/fixture.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 diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte index eb9e7286d..6688f43f2 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte @@ -7,11 +7,15 @@ window.__impeccableStatefulMounts = (window.__impeccableStatefulMounts || 0) + 1; }); + const CATALOG = [ + { name: 'Design snack', amount: '$12' }, + { name: 'Studio coffee', amount: '$8' }, + { name: 'Type license', amount: '$44' }, + ]; + function addExpense() { - expenses = [ - ...expenses, - { id: expenses.length + 1, name: 'Design snack', amount: '$12' }, - ]; + const next = CATALOG[expenses.length % CATALOG.length]; + expenses = [...expenses, { id: expenses.length + 1, ...next }]; } @@ -31,10 +35,14 @@

    Fügt die nächste gemeinsame Ausgabe hinzu, dann landet sie hier.

    {:else} -
    - {expenses[0].name} - {expenses[0].amount} -
    +
      + {#each expenses as expense, i} +
    • + {expense.name} + {expense.amount} +
    • + {/each} +
    {/if} @@ -96,18 +104,33 @@ color: #1b3329; } - .empty-card, - .expense-row { + .empty-card { border: 1px solid #16332b; border-radius: 18px; background: rgba(255, 255, 255, 0.62); padding: 26px; } + /* The padding is what the E2E picker aims at: a container whose centre is + covered by a child can never be hovered, so the list keeps a band of its + own around the rows. */ + .expense-list { + display: grid; + gap: 14px; + margin: 0; + padding: 24px; + list-style: none; + font-weight: 500; + } + .expense-row { display: flex; justify-content: space-between; align-items: center; + border: 1px solid #16332b; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + padding: 26px; } .detect-target { diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json index 0fee6a8f8..b83acc5e9 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json @@ -16,11 +16,57 @@ "expectedPreviewMode": "svelte-component" }, { - "name": "wraps stateful expense row through Svelte component preview", - "args": { "classes": "expense-row", "tag": "article" }, + "name": "wraps the each-block list through Svelte component preview", + "args": { "classes": "expense-list", "tag": "ul" }, "expectedFile": "node_modules/.impeccable-live/wraptest1/manifest.json", "expectedSourceFile": "src/routes/+page.svelte", "expectedPreviewMode": "svelte-component" } - ] + ], + "runtime": { + "styling": "plain-css", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "steer": false, + "pickSelector": "ul.expense-list", + "pickPosition": { "x": 10, "y": 10 }, + "variantSequence": [3, 1, 2], + "acceptedSourcePattern": "]*class=\"[^\"]*\\bexpense-list\\b", + "assertSourceContains": [ + "{#each expenses as expense, i}", + "{expense.name}", + "{expense.amount}" + ], + "preActions": [ + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='0']" }, + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='1']" }, + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='2']" } + ], + "stateProbe": { + "textSelector": "[data-testid='open-count']", + "expectedText": "3 offen", + "windowProperty": "__impeccableStatefulMounts", + "expectedWindowValue": 1, + "expectWindowUnchanged": true + }, + "paramsScenario": { + "variant": 2, + "rangeLabel": "Lead", + "rangeValue": 1.8, + "stepsLabel": "Density", + "stepsOptionLabel": "Snug", + "expectSourceContains": ["line-height: 1.8", "letter-spacing: 0.01em"], + "expectSourceMissing": ["letter-spacing: 0.14em"] + }, + "componentFailureScenarios": { "variant": 2, "storageLoss": false }, + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + } + } } diff --git a/tests/framework-fixtures/vite8-sveltekit/fixture.json b/tests/framework-fixtures/vite8-sveltekit/fixture.json index a75956e0a..221ce7197 100644 --- a/tests/framework-fixtures/vite8-sveltekit/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit/fixture.json @@ -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 diff --git a/tests/live-accept-css.test.mjs b/tests/live-accept-css.test.mjs new file mode 100644 index 000000000..80beaffba --- /dev/null +++ b/tests/live-accept-css.test.mjs @@ -0,0 +1,199 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { compile } from 'svelte/compiler'; +import { + bakeParamValues, + collectAllSelectors, + normalizeSelector, + parseStylesheet, + pruneUnusedSelectors, + reconcileCss, + serializeNodes, + splitSelectorList, + stripParamSelector, + substituteParamVar, +} from '../skill/scripts/live/accept-css.mjs'; +import { verifyAcceptedSource } from '../skill/scripts/live/accept-verify.mjs'; + +describe('accept-time CSS reconciliation', () => { + it('parses rules, at-blocks, and comments with stable round-trip', () => { + const css = `/* note */\n.a { color: red; }\n@media (min-width: 600px) {\n .a { color: blue; }\n .b { margin: 0; }\n}\n@font-face { font-family: X; src: url("a{b}.woff"); }`; + const nodes = parseStylesheet(css); + assert.deepEqual(nodes.map((n) => n.type), ['comment', 'rule', 'at', 'at']); + assert.equal(nodes[2].children.length, 2); + const out = serializeNodes(nodes); + assert.match(out, /@media \(min-width: 600px\)/); + assert.match(out, /a\{b\}\.woff/); + }); + + it('replaces matching selectors instead of appending duplicates', () => { + const existing = `.pit-board { border-top: 1px solid #333; padding: 8px; }\n.pit-board .label { color: gray; }`; + const variant = `.pit-board { padding: 12px; clip-path: polygon(0 0); }\n.pit-board .arrow { width: 10px; }`; + const { css, replaced, appended } = reconcileCss(existing, variant); + assert.equal(replaced, 1); + assert.equal(appended, 1); + // The superseded divider border is gone; the new body wins. + assert.doesNotMatch(css, /border-top/); + assert.match(css, /clip-path/); + assert.match(css, /\.pit-board \.label/); + assert.match(css, /\.pit-board \.arrow/); + // Exactly one .pit-board rule remains. + assert.equal(css.split('.pit-board {').length - 1, 1); + }); + + it('merges inside matching media queries', () => { + const existing = `@media (max-width: 700px) { .row { gap: 4px; } }`; + const variant = `@media (max-width: 700px) { .row { gap: 8px; } .col { gap: 2px; } }`; + const { css } = reconcileCss(existing, variant); + assert.match(css, /gap: 8px/); + assert.doesNotMatch(css, /gap: 4px/); + assert.match(css, /\.col \{ gap: 2px; \}/); + assert.equal(css.split('@media').length - 1, 1); + }); + + it('normalizes selectors for matching', () => { + assert.equal(normalizeSelector('.a > .b, .c'), '.a>.b,.c'); + assert.deepEqual(splitSelectorList('.a[data-x="1,2"], .b:is(.c, .d)'), ['.a[data-x="1,2"]', '.b:is(.c, .d)']); + }); +}); + +describe('param baking', () => { + it('substitutes range vars with paren-aware fallbacks', () => { + const css = `.x { width: calc(var(--p-depth, calc(2px * 3)) + 1px); opacity: var(--p-depth); }`; + const out = substituteParamVar(css, 'depth', '10px'); + assert.equal(out, `.x { width: calc(10px + 1px); opacity: 10px; }`); + }); + + it('keeps only the chosen steps branch and strips the attribute selector', () => { + const css = [ + `:global([data-p-density="airy"]) .grid { gap: 24px; }`, + `:global([data-p-density="snug"]) .grid { gap: 8px; }`, + `.grid { display: grid; }`, + ].join('\n'); + const out = bakeParamValues(css, [ + { id: 'density', kind: 'steps', default: 'airy' }, + ], { density: 'snug' }); + assert.doesNotMatch(out, /24px/); + assert.doesNotMatch(out, /data-p-density/); + assert.match(out, /\.grid \{ gap: 8px; \}/); + assert.match(out, /\.grid \{ display: grid; \}/); + }); + + it('normalizes toggle booleans to 0/1 and resolves presence selectors', () => { + const css = `.t { opacity: var(--p-serif, 0); }\n[data-p-serif] .t { font-family: serif; }`; + const on = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: true }); + assert.match(on, /opacity: 1/); + assert.match(on, /\.t \{ font-family: serif; \}/); + const off = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: false }); + assert.match(off, /opacity: 0/); + assert.doesNotMatch(off, /font-family: serif/); + }); + + it('falls back to declared defaults when no value was sent', () => { + const css = `.x { gap: var(--p-gap, 4px); }`; + const out = bakeParamValues(css, [{ id: 'gap', kind: 'range', default: 12 }], {}); + assert.match(out, /gap: 12/); + }); + + it('bakes undeclared sent values as ranges instead of ignoring them', () => { + const css = `.x { gap: var(--p-mystery, 4px); }`; + const out = bakeParamValues(css, [], { mystery: '9px' }); + assert.match(out, /gap: 9px/); + }); + + it('drops rules whose bodies become empty and strips readiness sentinels', () => { + const css = `.x { --impeccable-variant-ready: 1; }\n.y { color: red; }`; + const out = bakeParamValues(css, [], {}); + assert.doesNotMatch(out, /impeccable-variant-ready/); + assert.doesNotMatch(out, /\.x/); + assert.match(out, /\.y/); + }); + + it('stripParamSelector cleans emptied :global wrappers', () => { + assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'x'), '.b'); + assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'y'), null); + assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', true), '.root .b'); + assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', false), null); + }); +}); + +describe('compiler-driven pruning', () => { + it('removes selectors svelte reports as unused', () => { + const component = `
    hi
    \n`; + 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('
    {broken', () => { throw new Error('nope'); }); + assert.equal(source, '
    {broken'); + }); +}); + +describe('postcondition scanner', () => { + it('flags every class of live-mode leftover with line numbers', () => { + const dirty = [ + '
    x
    ', + '', + '.x { width: var(--p-depth, 4px); }', + '
    y
    ', + '', + ].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('
    ok
    \n'); + 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']); + }); +}); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 2106bc1a6..c46a9456f 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -1082,6 +1082,257 @@ describe('live-browser.js regression guards', () => { ); }); + it('acknowledges every successful component mount back to the server', () => { + // `arrivedVariants` counts what the agent published. Without this ack the + // server has no way to tell "the user is comparing three variants" from + // "three modules 404'd and the page is blank". + assert.match( + SOURCE, + /reportVariantMounted\(sessionId, variantNum, moduleUrl\);/, + 'a successful mount must post variant_mounted so the journal carries render truth', + ); + assert.match( + SOURCE, + /function reportVariantMounted\([\s\S]{0,400}?sendEvent\(\{\s*type: 'variant_mounted',/, + 'variant_mounted must go through the normal event POST helper', + ); + }); + + it('reports mount failures to the server instead of only logging them', () => { + assert.match( + SOURCE, + /console\.error\('\[impeccable\] Failed to mount component variant[\s\S]{0,200}?reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);/, + 'event=live_browser.silent_mount_failure actor=browser operation=mount_component_variant risk=agent_never_learns_render_failed expected=variant_mount_failed posted actual=console.error only', + ); + assert.match( + SOURCE, + /function reportVariantMountFailed\([\s\S]{0,900}?sendEvent\(\{ type: 'variant_mount_failed', id: sessionId, variant, url, error: message \}\);/, + 'variant_mount_failed must carry the failed module URL and the error text', + ); + // Both the first mount and a variant switch route through this one catch, + // so the switch path can no longer revert with zero feedback. + assert.match( + SOURCE, + /reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);[\s\S]{0,300}?showMountErrorCard\(sessionId, \{/, + 'a failed mount must raise the persistent error card from the shared catch', + ); + }); + + it('keeps the session alive when a component variant fails to mount', () => { + const abort = SOURCE.match( + /function abortSvelteComponentInjection\(sessionId, details\) \{[\s\S]*?\n \}\n/, + ); + assert.ok(abort, 'abortSvelteComponentInjection must still exist'); + const body = abort[0]; + assert.doesNotMatch( + body, + /clearSession\(\)/, + 'event=live_browser.mount_failure_wipe actor=browser operation=abort_svelte_injection risk=durable_session_orphaned expected=localStorage preserved actual=clearSession() called', + ); + assert.doesNotMatch( + body, + /currentSessionId = null/, + 'the session id is the only handle Retry and a republish have; abort must not drop it', + ); + assert.doesNotMatch( + body, + /setLiveState\('PICKING'\)/, + 'a mount failure is not the end of the session, so the bar must not fall back to PICKING', + ); + assert.doesNotMatch( + body, + /showToast\(/, + 'the 5s toast was replaced by a persistent card; a toast that vanishes reads as no feedback at all', + ); + assert.match(body, /showMountErrorCard\(/, 'abort must raise the persistent error card'); + assert.match(body, /saveSession\(\)/, 'abort must keep the localStorage cache in step with the server'); + }); + + it('gives the manifest fetch failure and the session mismatch the same error card', () => { + assert.doesNotMatch( + SOURCE, + /if \(manifest\.id !== sessionId\) return;/, + 'event=live_browser.silent_manifest_mismatch actor=browser operation=inject_from_manifest risk=bar_stuck_in_generating expected=error card actual=bare return', + ); + assert.match( + SOURCE, + /if \(manifest\.id !== sessionId\) \{[\s\S]{0,700}?showMountErrorCard\(sessionId, \{/, + 'a manifest belonging to another session must surface, not disappear', + ); + assert.match( + SOURCE, + /Failed to mount component-preview variants:[\s\S]{0,400}?reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, manifestPath, err\);/, + 'a manifest that cannot be read is a render failure the agent must hear about', + ); + assert.doesNotMatch( + SOURCE, + /reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, url,/, + 'the /source fetch URL carries the live token and must never be journaled; report the manifest path', + ); + }); + + it('offers a retry that re-runs injection for the same session', () => { + assert.match( + SOURCE, + /function retryMountErrorCard\(\) \{[\s\S]{0,900}?injectSvelteComponentsFromManifest\(manifestPath, sessionId\);/, + 'the Retry button must re-enter the normal injection path rather than restarting the session', + ); + assert.match( + SOURCE, + /function truncateMiddle\(value, max\)/, + 'the card shows the failed module URL truncated in the middle so both ends stay readable', + ); + }); + + it('rehydrates from the server when localStorage has no session', () => { + assert.doesNotMatch( + SOURCE, + /function restoreSessionWithoutWrapper\(reason, activeSessions\) \{\s*const saved = loadSession\(\);/, + 'event=live_browser.storage_gated_restore actor=browser operation=sse_connected risk=durable_session_unreachable expected=server summary can seed a restore actual=localStorage is a gate', + ); + assert.match( + SOURCE, + /const adopted = cached\?\.id \? null : findAdoptableServerSession\(activeSessions\);/, + 'a page with no cached session must be able to adopt the durable one the server reports', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?&& session\.pageUrl\s*&& pageMatchesCurrent\(session\.pageUrl\)/, + 'adoption must require an explicit pageUrl match so it cannot hijack an unrelated route', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?!isTerminalSessionSummary\(session\)/, + 'accepted, discarded, and completed sessions must never be adopted', + ); + }); + + it('carries no agent phase the server cannot emit', () => { + // Every one of these lived in PHASE_RANK and, for most, in a status string + // the user could never see: recordAgentPhase() in live-server.mjs has + // never emitted them. The validator now rejects them outright, so a + // leftover branch here is a branch that cannot run. + for (const retired of [ + 'first_variant_generating', + 'first_variant_validating', + 'remaining_variants_generating', + 'remaining_variants_validating', + 'variant_parameters_generating', + 'variant_parameters_validating', + 'parameters_ready', + ]) { + assert.doesNotMatch( + SOURCE, + new RegExp(retired), + 'event=live_browser.dead_agent_phase actor=browser operation=phase_rank risk=ui_branches_on_phase_nothing_sends phase=' + retired, + ); + } + // The phases the server does emit must all still rank. + for (const live of [ + 'picked_up', 'scaffolding', 'scaffold_fallback', 'source_ready', + 'generation_ready', 'first_reviewable', 'second_reviewable', 'all_variants_ready', + ]) { + assert.match(SOURCE, new RegExp('\\n\\s+' + live + ': \\d+,'), live + ' must keep a rank'); + } + }); + + it('renders the cycling counter through one function with one denominator', () => { + // buildCyclingRow showed visibleVariant/expectedVariants while + // syncCyclingControls wrote shown/arrivedVariants, so the same unchanged + // state rendered as "2/3" or "2/2" depending on which path ran last. + assert.doesNotMatch( + SOURCE, + /visibleVariant \+ '\/' \+ expectedVariants/, + 'event=live_browser.counter_drift actor=browser operation=cycling_counter risk=two_denominators_for_one_counter', + ); + assert.doesNotMatch(SOURCE, /shown \+ '\/' \+ arrivedVariants/); + assert.match( + SOURCE, + /function cyclingCounterText\(\) \{[\s\S]{0,300}?arrivedVariants > 0 \? arrivedVariants : expectedVariants/, + 'the denominator policy is arrived-when-known, expected otherwise, in one place', + ); + const counterAssignments = SOURCE.match(/-variant-counter'\)?;?[\s\S]{0,120}?textContent = ([^;]+);/g) || []; + for (const assignment of counterAssignments) { + assert.match(assignment, /cyclingCounterText\(\)/, 'every counter write goes through cyclingCounterText'); + } + }); + + it('gives the steer bar a visible Send control alongside Enter', () => { + assert.match( + SOURCE, + /pageChatSendBtn = el\('button'/, + 'event=live_browser.steer_send_affordance actor=user operation=steer risk=enter_only_submit_reads_as_dead_input', + ); + assert.match(SOURCE, /pageChatSendBtn\.id = PREFIX \+ '-page-chat-send'/); + assert.match( + SOURCE, + /function syncPageChatSendButton\(\)[\s\S]{0,900}?pageChatSendBtn\.disabled = !visible \|\| !hasText/, + 'Send is disabled with an empty input', + ); + assert.match( + SOURCE, + /function syncPageChatSendButton\(\)[\s\S]{0,900}?const visible = !steerLocked/, + 'Send disappears while a steer is in flight', + ); + // Enter must still submit. + assert.match( + SOURCE, + /if \(e\.key === 'Enter'\) \{\s*e\.preventDefault\(\);\s*submitSteerMessage\(\);/, + 'keyboard submit stays', + ); + assert.match( + SOURCE, + /PREFIX \+ '-page-chat-send'\] \}/, + 'the Send control must be registered as live UI chrome so it is excluded from capture', + ); + }); + + it('says a queued steer is queued instead of pulsing at the user', () => { + assert.match( + SOURCE, + /function steerQueuedBehindGeneration\(\) \{[\s\S]{0,220}?steerLocked && !agentPollingConnected && agentHasWorkInFlight\(\)/, + 'event=live_browser.steer_queue_feedback actor=user operation=steer_during_generation risk=queued_request_reads_as_lost', + ); + assert.match(SOURCE, /Queued behind current generation/); + assert.match( + SOURCE, + /function syncSteerQueueHint\(\)[\s\S]{0,400}?pageChatDotsEl\.style\.display = 'none'/, + 'the queue hint replaces the bare dots rather than sitting beside them', + ); + assert.match(SOURCE, /function syncAgentPollingUi\(connected\) \{[\s\S]{0,140}?syncSteerQueueHint\(\)/); + }); + + it('names the actual cause when a steer times out', () => { + assert.doesNotMatch( + SOURCE, + /Check that live-poll is running and replies with steer_done/, + 'event=live_browser.steer_timeout_copy actor=user operation=steer_timeout risk=blames_live_poll_for_a_busy_agent', + ); + assert.match( + SOURCE, + /function steerTimeoutMessage\(\)[\s\S]{0,900}?steerQueuedBehindGeneration\(\)[\s\S]{0,400}?!agentPollingConnected/, + 'the timeout message branches on agent-busy vs nobody-polling', + ); + }); + + it('distinguishes an empty DESIGN.md from a missing one in the design panel', () => { + assert.match( + SOURCE, + /function designEmptyMessage\(\)[\s\S]{0,700}?designState\.hasMd && !designState\.hasSidecar[\s\S]{0,300}?DESIGN\.md found, no structured tokens to display/, + 'event=live_browser.design_empty_state actor=user operation=open_design_panel risk=present_design_md_reported_as_missing', + ); + assert.match( + SOURCE, + /const beforeCount = body\.childElementCount;/, + 'the empty check must ignore the stale hint and CTA the caller already appended', + ); + assert.doesNotMatch( + SOURCE, + /msgDiv\('empty', 'No design system data available\.'\)/, + 'the bare message may only survive as the genuinely-absent branch of designEmptyMessage', + ); + }); + it('editing focus timeout does not read a stale inline edit row', () => { assert.doesNotMatch( SOURCE, @@ -1094,4 +1345,21 @@ describe('live-browser.js regression guards', () => { 'edit-mode delayed focus should capture the element before scheduling and no-op if editing ended before the timeout fires', ); }); + it('adopts only variant comparisons from the server, never steer or manual sessions', () => { + // A completed-steer session is non-terminal and carries a sourceFile; + // adopting it as a comparison hunts for a variant wrapper that never + // existed and wedges the bar before the user's next pick (found by the + // vite8-react-base-path e2e after server-first rehydration landed). + assert.match( + SOURCE, + /function findAdoptableServerSession\([\s\S]{0,900}?Number\(session\.expectedVariants\) > 0/, + 'adoption must require a generation-shaped session (expectedVariants > 0)', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\([\s\S]{0,900}?\^\(steer\|manual_edit\)/, + 'adoption must exclude steer and manual-edit phases', + ); + }); + }); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index a6be62570..a232a4ffe 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -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'), / expectedCount + 6) { - throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`); - } - if (visible == null || visible < targetVariant) await clickNext(page); - else await clickPrev(page); - visible = await readVisibleVariantForCycle(page); - } - assert.equal(visible, targetVariant, `variant ${targetVariant} visible`); - if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) { - await page.waitForFunction((sel) => { - const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); - const el = query(sel) || document.querySelector(sel); - return el && getComputedStyle(el).fontWeight === '900'; - }, variantContentSelector, { timeout: 5_000 }).catch(() => {}); - const variantWeight = await evaluatePageWithTimeout( + // Render proof, not bar-label proof: the fake agent gives every variant + // a distinct font-weight, so a computed read says which variant the + // user is actually looking at. Checked for each variant the run reaches. + const styleCheckedVariants = new Set(); + const assertVisibleVariantStyle = async (variantNum) => { + if (agentMode !== 'fake') return; + const expected = FAKE_VARIANT_FONT_WEIGHTS[variantNum]; + if (!expected || styleCheckedVariants.has(variantNum)) return; + styleCheckedVariants.add(variantNum); + const selector = variantContentSelectorFor(variantNum); + await waitForComputedFontWeight(page, selector, expected, { timeout: 5_000 }).catch(() => {}); + const variantWeight = await readComputedFontWeight(page, selector).catch(() => null); + if (variantWeight !== expected) { + const styleSnapshot = await evaluatePageWithTimeout( page, (sel) => { const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); const el = query(sel) || document.querySelector(sel); - return el ? getComputedStyle(el).fontWeight : null; - }, - variantContentSelector, - 5_000, - 'variant font-weight read', - ); - if (variantWeight !== '900') { - const styleSnapshot = await evaluatePageWithTimeout( - page, - (sel) => { - const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); - const el = query(sel) || document.querySelector(sel); - const styleEl = document.querySelector('style[data-impeccable-css]'); - const rules = []; - for (const sheet of [...document.styleSheets]) { - if (sheet.ownerNode !== styleEl) continue; - try { - rules.push(...[...sheet.cssRules].map((rule) => rule.cssText)); - } catch (err) { - rules.push(`cssRules error: ${err.message}`); - } + const styleEl = document.querySelector('style[data-impeccable-css]'); + const rules = []; + for (const sheet of [...document.styleSheets]) { + if (sheet.ownerNode !== styleEl) continue; + try { + rules.push(...[...sheet.cssRules].map((rule) => rule.cssText)); + } catch (err) { + rules.push(`cssRules error: ${err.message}`); } - return { - selector: sel, - element: el?.outerHTML || null, - parent: el?.parentElement?.outerHTML?.slice(0, 800) || null, - computedWeight: el ? getComputedStyle(el).fontWeight : null, - styleText: styleEl?.textContent || null, - rules, - }; - }, - variantContentSelector, - 5_000, - 'variant style snapshot', - ).catch((err) => ({ error: err.message })); - t.diagnostic('--- variant style snapshot ---'); - t.diagnostic(JSON.stringify(styleSnapshot, null, 2)); - } - assert.equal( - variantWeight, - '900', - 'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape', - ); - checkedVariantTwoStyle = true; + } + return { + selector: sel, + element: el?.outerHTML || null, + parent: el?.parentElement?.outerHTML?.slice(0, 800) || null, + computedWeight: el ? getComputedStyle(el).fontWeight : null, + styleText: styleEl?.textContent || null, + rules, + }; + }, + selector, + 5_000, + 'variant style snapshot', + ).catch((err) => ({ error: err.message })); + t.diagnostic(`--- variant ${variantNum} style snapshot ---`); + t.diagnostic(JSON.stringify(styleSnapshot, null, 2)); } + assert.equal( + variantWeight, + expected, + `event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant variant=${variantNum} risk=unstyled_live_preview expected=font-weight ${expected} actual=${variantWeight} suggestion=inspect live CSS style mode and selector shape`, + ); + }; + await assertVisibleVariantStyle(visible); + for (const targetVariant of cycleSequence) { + t.diagnostic(`Cycling to variant ${targetVariant}`); + visible = await cycleToVariant(page, targetVariant, expectedCount, { + settleTimeout: agentMode === 'llm' ? 60_000 : 15_000, + }); + assert.equal(visible, targetVariant, `variant ${targetVariant} visible`); + await assertVisibleVariantStyle(targetVariant); } if (reloadVariants && usesSvelteComponentPreview) { @@ -554,11 +604,11 @@ for (const { name, fixture } of fixtures) { const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget }); if (svelteComponentTarget) { assert.equal(existsSync(svelteComponentTarget.manifestPath), false, 'Svelte temp preview session removed after accept'); - const snapshotPath = join(tmp, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`); + const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`); const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')); assert.equal(snapshot.phase, 'completed'); assert.equal(snapshot.sourceFile, svelteComponentTarget.manifest.sourceFile); - assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live/); + assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live|\.impeccable\/live\/previews/); } assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed'); assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed'); @@ -627,11 +677,22 @@ for (const { name, fixture } of fixtures) { await page.waitForSelector(expectSelector, { timeout: 10_000 }); } + // 9c. Network hygiene — nothing under the live preview/runtime tree + // may 404 or fail. A missing `.impeccable-live` module means the + // preview never mounted, which the DOM assertions above can miss + // when the original element still renders. + const liveTreeFailures = (session.failedRequests || []).filter((f) => isLiveTreeUrl(f.url)); + assert.equal( + liveTreeFailures.length, + 0, + `live preview/runtime requests must all succeed, got:\n${ + liveTreeFailures.map((f) => `${f.reason} ${f.url}`).join('\n') + }`, + ); + // 10. Console hygiene — no errors during the whole flow. if (fixture.runtime.probe?.expectConsoleClean) { - const realErrors = consoleErrors.filter((e) => - !/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(e), - ); + const realErrors = consoleErrors.filter((e) => !isBenignConsoleError(e)); if (realErrors.length > 0) { t.diagnostic('--- console errors ---'); for (const e of realErrors) t.diagnostic(e); @@ -667,6 +728,201 @@ for (const { name, fixture } of fixtures) { + // ----------------------------------------------------------------- + // Params end-to-end: knob → accept → baked literal in source. + // + // The Svelte accept pipeline bakes params mechanically, so the unit + // tests already cover the transform. What they cannot cover is that the + // value the user actually dialled in the Tune popover is the value that + // reaches it. This drives the real controls and reads the real source. + // ----------------------------------------------------------------- + if (shouldRunScenario('params') && fixture.runtime.paramsScenario) { + it('bakes tuned param values into the accepted source', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const scenario = fixture.runtime.paramsScenario; + const targetVariant = scenario.variant || 2; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session, sourceFile, svelteComponentTarget } = run; + const { page } = session; + try { + await cycleToVariant(page, targetVariant, 3); + + t.diagnostic('Opening the Tune popover'); + await openTunePanel(page); + const applied = await setTuneRange(page, scenario.rangeLabel, scenario.rangeValue); + assert.equal(applied, scenario.rangeValue, 'range knob took the requested value'); + await chooseTuneStep(page, scenario.stepsLabel, scenario.stepsOptionLabel); + + t.diagnostic(`Accepting variant ${targetVariant} with tuned params`); + await clickAccept(page, { expectedVariant: targetVariant }); + await waitForBarHidden(page); + const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget }); + + for (const needle of scenario.expectSourceContains || []) { + assert.ok( + final.includes(needle), + `accepted source should bake ${JSON.stringify(needle)}; got:\n${final}`, + ); + } + for (const needle of scenario.expectSourceMissing || []) { + assert.equal( + final.includes(needle), + false, + `accepted source should drop the unchosen branch ${JSON.stringify(needle)}; got:\n${final}`, + ); + } + // The generic contract, independent of this fixture's values: every + // preview-only param hook is gone once the source is accepted. + assert.doesNotMatch(final, /data-p-[A-Za-z0-9_-]+/, 'no data-p-* param attributes survive accept'); + assert.doesNotMatch(final, /var\(--p-/, 'no unbaked var(--p-*) survives accept'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + // ----------------------------------------------------------------- + // Failure injection for component previews. + // ----------------------------------------------------------------- + if (fixture.runtime.componentFailureScenarios) { + const failure = fixture.runtime.componentFailureScenarios; + const brokenVariant = failure.variant || 2; + + if (shouldRunScenario('mount-failure')) { + it('surfaces a broken variant without losing the session, and recovers on Retry', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + // Auto-repair off: this scenario is about what the USER sees and can + // do when a publish is unrenderable. An agent that silently + // republishes would hide exactly the surface under test. + const run = await bootComponentScenario({ + t, + name, + fixture, + expectedCount: 3, + agent: createFakeAgent({ autoRepairMountFailures: false }), + }); + const { session, svelteComponentTarget } = run; + const { page, appRoot } = session; + try { + const sessionId = svelteComponentTarget.manifest.id; + const variantPath = revisionVariantPath(appRoot, run.manifestPath, brokenVariant); + const saved = readFileSync(variantPath, 'utf-8'); + // Corrupt rather than delete: a published variant that does not + // compile is the failure an agent actually produces, and it keeps + // the module resolvable so the recovery is about the content. + t.diagnostic(`Corrupting published revision file ${relative(appRoot, variantPath)}`); + writeFileSync(variantPath, '
    { not valid svelte\n', 'utf-8'); + + // One step forward onto the variant whose module is gone. The step + // deliberately does not settle, so drive the click directly. + t.diagnostic(`Stepping onto the broken variant ${brokenVariant}`); + await clickNext(page); + + const cardText = await waitForMountErrorCard(page, { variant: brokenVariant }); + assert.match(cardText, /Retry/, 'the mount-error card offers a Retry'); + + // The session must survive the failure: the old behaviour wiped + // local state and orphaned a session the server still tracked. + const barVisible = await page.evaluate(() => { + const bar = window.__impeccableLiveQuery('#impeccable-live-bar'); + return Boolean(bar) && bar.style.display !== 'none'; + }); + assert.equal(barVisible, true, 'the cycling bar survives a mount failure'); + const stored = await readLiveSessionStorage(page); + assert.equal(stored?.id, sessionId, 'the local session record survives a mount failure'); + + const events = await waitForJournalEvent(appRoot, sessionId, 'variant_mount_failed', 15_000); + const failedEvent = events.at(-1); + assert.equal(failedEvent.variant, brokenVariant, 'the journal names the variant that failed'); + assert.ok(failedEvent.error, 'the journal carries the mount error text'); + + // A failed step reverts to the variant that still renders, so the + // comparison is usable rather than blank; Retry re-imports the + // session, and the repaired variant is reachable again. + t.diagnostic('Restoring the revision file and clicking Retry'); + writeFileSync(variantPath, saved, 'utf-8'); + await clickMountRetry(page); + await waitForMountErrorCardGone(page); + await cycleToVariant(page, brokenVariant, 3, { settleTimeout: 20_000 }); + await waitForComputedFontWeight( + page, + fixture.runtime.pickSelector || 'h1.hero-title', + FAKE_VARIANT_FONT_WEIGHTS[brokenVariant], + { timeout: 20_000 }, + ); + assert.equal(await getVisibleVariant(page), brokenVariant, 'the repaired variant is the visible one'); + assert.equal(await isMountErrorCardVisible(page), false, 'the repaired mount raises no new error'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + if (shouldRunScenario('republish')) { + it('mounts republished variant content instead of a cached compile', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session } = run; + const { page, appRoot } = session; + const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; + try { + await cycleToVariant(page, brokenVariant, 3); + await waitForComputedFontWeight(page, pickSelector, FAKE_VARIANT_FONT_WEIGHTS[brokenVariant]); + const beforeRevision = readManifest(run.manifestPath).revision; + + t.diagnostic('Republishing every variant with new styling'); + await republishSvelteComponentVariants({ + tmp: appRoot, + manifestFile: relative(appRoot, run.manifestPath), + live: session.live, + css: (variantNum, shape) => `${shape.rootSelector} { font-weight: ${100 * variantNum}; }`, + }); + + // A stale transform cache would keep serving the previous compile; + // the server-stamped revision dir is what makes the import path new. + await waitForComputedFontWeight(page, pickSelector, String(100 * brokenVariant), { timeout: 20_000 }); + const afterRevision = readManifest(run.manifestPath).revision; + assert.ok( + afterRevision > beforeRevision, + `republish must bump the preview revision (${beforeRevision} → ${afterRevision})`, + ); + assert.equal(await isMountErrorCardVisible(page), false, 'a clean republish shows no mount error'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + if (shouldRunScenario('storage-loss') && failure.storageLoss !== false) { + it('rehydrates the comparison from the server after localStorage is cleared', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session } = run; + const { page } = session; + try { + await cycleToVariant(page, brokenVariant, 3); + assert.ok(await readLiveSessionStorage(page), 'a local session record exists before the wipe'); + + t.diagnostic('Clearing localStorage and reloading'); + await page.evaluate(() => localStorage.clear()); + assert.equal(await readLiveSessionStorage(page), null, 'the local session record really was wiped'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForHandshake(page); + if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions); + + // Nothing local is left: reaching CYCLING again can only come from + // the server's durable session record. + await waitForCycling(page, 3, { timeout: 30_000 }); + const restored = await readLiveSessionStorage(page); + assert.equal(restored?.id, run.svelteComponentTarget.manifest.id, 'the adopted session keeps its id'); + assert.equal(restored?.expected, 3, 'the adopted session knows the variant count'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + } + if (shouldRunScenario('missed-done') && fixture.runtime.missedDoneReloadScenario) { it('recovers when the preflight reload makes the browser miss the done broadcast', liveE2eTestOptions, async (t) => { if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { @@ -692,7 +948,7 @@ for (const { name, fixture } of fixtures) { atomicDelayMs: 2500, log: (m) => t.diagnostic(m), }); - const { page, tmp, teardown } = session; + const { page, appRoot, teardown } = session; const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; try { await waitForHandshake(page); @@ -731,7 +987,7 @@ for (const { name, fixture } of fixtures) { // this stale copy, forcing the completion-driven fallback through // its retry path — a single no-retry read here strands the tab in // GENERATING forever. - const scaffoldSourceFile = join(tmp, fixture.runtime.missedDoneReloadScenario.sourceFile); + const scaffoldSourceFile = join(appRoot, fixture.runtime.missedDoneReloadScenario.sourceFile); const scaffoldOnlySource = readFileSync(scaffoldSourceFile, 'utf-8'); let staleSourceServed = false; await page.context().route('**/source?token=*', (route) => { @@ -867,7 +1123,7 @@ for (const { name, fixture } of fixtures) { assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists'); assert.match(generateEvent.screenshotPath, /\.impeccable\/live\/annotations\//, 'annotation screenshot is stored under live annotations'); - const sourceFile = await locateSessionFile(session.tmp); + const sourceFile = await locateSessionFile(session.appRoot); const svelteComponentTarget = svelteComponentTargetFor(sourceFile); await clickNext(page); assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate'); @@ -909,6 +1165,163 @@ for (const { name, fixture } of fixtures) { // Helpers // --------------------------------------------------------------------------- +/** + * The params and failure-injection scenarios assert on deterministic variant + * content (exact CSS values, exact font weights). An LLM agent legitimately + * produces neither, so they only run against the fake agent. + */ +function canRunFakeAgentScenario(t) { + if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { + t.skip('manual scenario filter is active'); + return false; + } + if ((process.env.IMPECCABLE_E2E_AGENT || 'fake') !== 'fake') { + t.skip('scenario asserts on deterministic fake-agent output'); + return false; + } + return true; +} + +/** + * Boot a fixture straight to CYCLING on a component-preview session and hand + * back the handles the scenarios need: the manifest, the route source, and the + * session itself. Callers own teardown. + */ +async function bootComponentScenario({ t, name, fixture, expectedCount = 3, agent }) { + const session = await bootFixtureSession({ + name, + fixture, + browser, + agent: agent || createFakeAgent(), + wrapTarget: wrapTargetFromPickedElement, + log: (m) => t.diagnostic(m), + }); + try { + const { page, appRoot } = session; + await waitForHandshake(page); + if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions); + await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title', { + position: fixture.runtime.pickPosition, + }); + await clickGo(page); + await waitForCyclingRobust(page, expectedCount, { + agentMode: 'fake', + preActions: fixture.runtime.preActions, + log: (m) => t.diagnostic(m), + }); + const manifestPath = await locateSessionFile(appRoot); + const svelteComponentTarget = svelteComponentTargetFor(manifestPath); + assert.ok(svelteComponentTarget, `expected a component-preview session, got ${manifestPath}`); + return { + session, + manifestPath, + svelteComponentTarget, + sourceFile: manifestPath, + }; + } catch (err) { + await teardownAndResetBrowser(session.teardown); + throw err; + } +} + +function readManifest(manifestPath) { + return JSON.parse(readFileSync(manifestPath, 'utf-8')); +} + +/** + * The file the browser actually imports for a variant: the server snapshots + * every publish into a fresh `r/` dir and points the manifest at it, so the + * session dir's copy is not what a mount reads. + */ +function revisionVariantPath(appRoot, manifestPath, variantNum) { + const manifest = readManifest(manifestPath); + const dir = manifest.revisionDir || manifest.componentDir; + assert.ok(dir, 'manifest carries a preview directory'); + const extension = manifest.componentExtension || 'svelte'; + return join(appRoot, dir, `v${variantNum}.${extension}`); +} + +/** Poll the session journal for events of a given type. */ +async function waitForJournalEvent(appRoot, sessionId, type, timeoutMs = 15_000) { + const journalPath = join(appRoot, '.impeccable', 'live', 'sessions', `${sessionId}.jsonl`); + const deadline = Date.now() + timeoutMs; + let seen = []; + while (Date.now() < deadline) { + seen = readJournalEvents(journalPath).filter((event) => event?.type === type); + if (seen.length > 0) return seen; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `no ${type} event in ${journalPath} after ${timeoutMs}ms; saw types: ${ + [...new Set(readJournalEvents(journalPath).map((e) => e?.type))].join(', ') || '(none)' + }`, + ); +} + +function readJournalEvents(journalPath) { + if (!existsSync(journalPath)) return []; + return readFileSync(journalPath, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => { + try { return JSON.parse(line)?.event || null; } catch { return null; } + }) + .filter(Boolean); +} + +/** + * Assert the roots live.mjs resolved for an `appDir` fixture. + * + * The boot ran from the staged repo root, which carries no dev-server config + * of its own. Live mode has to pick the nested app, keep PRODUCT.md/DESIGN.md + * discovery at the git root, and leave its session state under the app plus a + * pointer at the repo root. A repo-root server.json means the session forked + * into a second, empty project — the failure this fixture exists to catch. + */ +function assertNestedAppRoots(session) { + const { tmp, appRoot, appDir, liveBoot } = session; + assert.ok(liveBoot, 'appDir fixtures boot through live.mjs and keep its payload'); + assert.equal(liveBoot.ok, true, `live.mjs boot payload: ${JSON.stringify(liveBoot)}`); + + const roots = liveBoot.roots || {}; + assert.ok( + roots.appRoot && roots.appRoot.endsWith(`/${appDir}`), + `roots.appRoot should end with /${appDir}, got ${roots.appRoot}`, + ); + assertSamePath(roots.appRoot, appRoot, 'roots.appRoot'); + assertSamePath(roots.repoRoot, tmp, 'roots.repoRoot'); + assertSamePath(roots.contextRoot, tmp, 'roots.contextRoot'); + assertSamePath(liveBoot.projectRoot, appRoot, 'projectRoot'); + + assert.equal(liveBoot.hasProduct, true, 'PRODUCT.md at the repo root is read from the nested app'); + assert.equal(liveBoot.hasDesign, true, 'DESIGN.md at the repo root is read from the nested app'); + + assert.ok( + existsSync(join(appRoot, '.impeccable/live/roots.json')), + 'the resolved manifest is persisted under the app', + ); + assert.ok( + existsSync(join(appRoot, '.impeccable/live/server.json')), + 'the live server registers itself under the app', + ); + assert.ok( + existsSync(join(tmp, '.impeccable/live/app-root.json')), + 'the repo root gets a pointer to the app', + ); + assert.equal( + existsSync(join(tmp, '.impeccable/live/server.json')), + false, + 'no live session state may be written at the repo root', + ); +} + +function assertSamePath(actual, expected, label) { + const resolve = (p) => { + try { return realpathSync(String(p)); } catch { return String(p); } + }; + assert.equal(resolve(actual), resolve(expected), `${label}: ${actual} !== ${expected}`); +} + function recordGenerateEvents(agent, events) { return { ...agent, @@ -964,6 +1377,11 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error writeFileSync(join(dir, 'error.txt'), String(error?.stack || error?.message || error || ''), 'utf-8'); writeFileSync(join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2), 'utf-8'); writeFileSync(join(dir, 'console-errors.log'), (session.consoleErrors || []).join('\n'), 'utf-8'); + writeFileSync( + join(dir, 'failed-requests.log'), + (session.failedRequests || []).map((f) => `${f.reason} ${f.url}`).join('\n'), + 'utf-8', + ); writeFileSync(join(dir, 'dev-server.log'), session.dev?.log?.() || '', 'utf-8'); writeCommandOutput(dir, 'git-status.txt', tmp, ['status', '--short']); writeCommandOutput(dir, 'git-diff.patch', tmp, ['diff', '--', '.']); @@ -983,6 +1401,7 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error for (const file of walkSources(tmp)) copyFileFromTmp(tmp, file, join(dir, 'sources')); copyDirIfExists(join(tmp, '.impeccable', 'live'), join(dir, 'impeccable-live')); copyDirIfExists(join(tmp, 'node_modules', '.impeccable-live'), join(dir, 'impeccable-live-preview')); + copyDirIfExists(join(tmp, '.impeccable', 'live', 'previews'), join(dir, 'impeccable-live-previews')); if (session.page) { const html = await withCaptureTimeout(session.page.content(), 5_000, 'page content').catch((err) => `capture failed: ${err.message}`); @@ -1176,7 +1595,7 @@ async function runManualScenarioActions(page, actions, { t, fixture, session, de } async function runManualEditStage(page, stage, { t, fixture, session, agentMode, defaultSelector }) { - const { tmp } = session; + const tmp = session.appRoot; if (stage.beforeManualEdit) { await runManualScenarioActions(page, stage.beforeManualEdit, { @@ -1323,7 +1742,7 @@ async function runAcceptedVariantCycle(page, { t, fixture, session, pickSelector await clickNext(page); assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible before manual scenario accept'); await clickAccept(page, { expectedVariant: 2 }); - const sourceFile = await locateSessionFile(session.tmp); + const sourceFile = await locateSessionFile(session.appRoot); await waitForSourceClean(sourceFile, 20_000); await waitForBarHidden(page, { timeout: 10_000 }).catch(() => {}); @@ -1610,6 +2029,7 @@ function svelteComponentTargetFor(filePath) { if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null; const sep = pathSepFor(filePath); const markers = [ + `${sep}.impeccable${sep}live${sep}previews${sep}`, `${sep}node_modules${sep}.impeccable-live${sep}`, `${sep}src${sep}lib${sep}impeccable${sep}`, `${sep}app${sep}.impeccable-live${sep}`, @@ -1712,6 +2132,7 @@ async function locateSessionFile(tmp) { function walkComponentManifests(root) { const results = []; const stack = [ + join(root, '.impeccable/live/previews'), join(root, 'node_modules/.impeccable-live'), join(root, 'src/lib/impeccable'), join(root, 'app/.impeccable-live'), diff --git a/tests/live-e2e/agent.mjs b/tests/live-e2e/agent.mjs index 7b6ea8c85..68d54af8d 100644 --- a/tests/live-e2e/agent.mjs +++ b/tests/live-e2e/agent.mjs @@ -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

    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 = ``; - 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 \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/` 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} + */ +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}

    `; + 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(`$/.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 \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(`

    {fmt(user.name, { width: cols })}

    `, { 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 = `
      {#each expenses as expense, i (expense.id)}
    • {expense.name}
    • {/each}
    `; + 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 [ + `
      {#each rows as r (r)}
    • {r.x}
    • {/each}
    `, + `
      {#each rows as r, i (i)}
    • {r.x}
    • {/each}
    `, + ]) { + 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 = [ + [`
      {#each rows as r (globalThing)}
    • {r.x}
    • {/each}
    `, /not derived from the loop item/], + [`
      {#each rows as r (makeKey(r))}
    • {r.x}
    • {/each}
    `, /complex each key/], + [`
      {#each rows as r (r.name)}
    • {r.name}
    • {/each}
    `, /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 [ + [`
    {item.class}
    `, 'classValue'], + [`
    {cfg.default}
    `, 'defaultValue'], + [`
    {a.for}
    `, '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); + } + }); +}); diff --git a/tests/live-svelte-component-accept.test.mjs b/tests/live-svelte-component-accept.test.mjs new file mode 100644 index 000000000..88ae9d9dc --- /dev/null +++ b/tests/live-svelte-component-accept.test.mjs @@ -0,0 +1,232 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, symlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { + extractMatchingSourceCss, + findSvelteComponentManifest, + inlineSvelteComponentAccept, + mergeCssIntoSvelteSource, + reindentPreservingStructure, + scaffoldSvelteComponentSession, +} from '../skill/scripts/live/svelte-component.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_NODE_MODULES = join(__dirname, '..', 'node_modules'); + +const ROUTE_SOURCE = ` + +
    +
      + {#each stages as stage, i} +
    1. + {stage.label} +

      {stage.detail}

      +
    2. + {/each} +
    + +
    + + +`; + +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
      block: lines 10-17 (1-indexed). + const originalLines = ROUTE_SOURCE.split('\n').slice(9, 17); + assert.match(originalLines[0], /
        /); + 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: [''], + 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'), ` + +
          + {#each stages as stage, i} +
        1. + {stage.label} +

          {stage.detail}

          +
        2. + {/each} +
        + + +`); + 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'), ` + +
          + {#each stages as stage, i} +
        1. +
          + {stage.label} +
          +
        2. + {/each} +
        + + +`); + 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('
        ')); + 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([' ', ' ', ' '], ' '); + assert.deepEqual(out, [' ', ' ', ' ']); + }); + + it('mergeCssIntoSvelteSource creates a style block when none exists', () => { + const { text } = mergeCssIntoSvelteSource('
        hi
        ', '.x { color: red; }'); + assert.match(text, /