diff --git a/.gitignore b/.gitignore index f788a87e..f39ab557 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ tsconfig.tsbuildinfo .claude .roadmap .playwright-mcp +# Per-machine MCP server wiring: the entries carry absolute paths to this +# checkout and assume locally-installed binaries, so committing one breaks +# every other clone and CI. +.mcp.json tmp diff --git a/AGENTS.md b/AGENTS.md index eddf7147..6b73a5c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ This map routes an edit to the smallest sufficient claim plus any source-owned d | `packages/_integration/__tests__/**` | `vp run verify:integration` | | `packages/test-ds/src/**` | `vp run verify:unit:ts && vp run --fail-if-no-match -F '...@animus-ui/test-ds' verify` | | `e2e/packed-app/**` or `scripts/verify/packed.sh` | `vp run verify:packed` | -| `scripts/verify/topology.*` | `bunx vp test run scripts/verify/topology.test.ts && vp run verify:lint` | +| `scripts/verify/topology.*` | `vp run verify:lint` | | `packages/{properties,system,extract,vite-plugin,next-plugin}/package.json` (deps, peers, exports, files) | `vp run verify:packed` | | `.github/workflows/ci.yaml`, `scripts/**`, `.tool-versions` | `vp run verify:full` | | Worker orchestration (`vite.config.ts`, `scripts/verify/**`, root deploy scripts, Worker ignores) | `vp run verify:full` | diff --git a/e2e/next-app/scripts/assert-build.ts b/e2e/next-app/scripts/assert-build.ts index 88a30e94..967c64e8 100644 --- a/e2e/next-app/scripts/assert-build.ts +++ b/e2e/next-app/scripts/assert-build.ts @@ -7,6 +7,7 @@ import { assertKeyframesExtracted, assertLayerOrder, assertNoBootstrapScript, + assertNoDevDiagnostics, assertNoEmotionImports, assertNoPlaceholders, assertSystemFallbackParity, @@ -184,6 +185,10 @@ async function main(): Promise { ); } } + + // Production diagnostic elimination — the Next plugin's DefinePlugin entry + // is the fold's input here; see is-dev.ts for the define/fold story. + assertNoDevDiagnostics(js); } // Router coverage — same checks as the prior shell script. diff --git a/e2e/vite-app/scripts/assert-build.ts b/e2e/vite-app/scripts/assert-build.ts index 7bbda969..5523eb57 100644 --- a/e2e/vite-app/scripts/assert-build.ts +++ b/e2e/vite-app/scripts/assert-build.ts @@ -6,6 +6,7 @@ import { assertHeadInjectionContract, assertKeyframesExtracted, assertLayerOrder, + assertNoDevDiagnostics, assertNoEmotionImports, assertNoPlaceholders, assertSystemFallbackParity, @@ -338,6 +339,10 @@ async function main(): Promise { ); } } + + // Production diagnostic elimination — see is-dev.ts for the define/fold + // story. + assertNoDevDiagnostics(js); } // Root-import transform witness (extraction-dx remediation): App.tsx diff --git a/openspec/specs/builder-chain/spec.md b/openspec/specs/builder-chain/spec.md index c6dce333..80c3ed16 100644 --- a/openspec/specs/builder-chain/spec.md +++ b/openspec/specs/builder-chain/spec.md @@ -48,6 +48,11 @@ Terminal methods (`.asElement()`, `.asComponent()`, `.build()`) SHALL produce co - **WHEN** `.extend()` is called on a terminal component - **THEN** the AnimusExtended instance SHALL carry the same T, enabling scale-resolved autocomplete in extension styles +#### Scenario: Non-terminal builder stages reject extend() + +- **WHEN** `.extend()` is called on a builder stage that is not a terminal, such as `ds.styles({ ... }).extend()` +- **THEN** TypeScript SHALL produce a type error — `extend()` is offered only by terminal output, since a stage that was never materialized into a component has no configuration for the extraction pipeline to resolve + #### Scenario: build() produces a raw style function - **WHEN** `.build()` is called instead of a terminal diff --git a/openspec/specs/compose-css-propagation/spec.md b/openspec/specs/compose-css-propagation/spec.md index cd848440..2904213e 100644 --- a/openspec/specs/compose-css-propagation/spec.md +++ b/openspec/specs/compose-css-propagation/spec.md @@ -1,6 +1,6 @@ ## Purpose -Requirements for the `compose-css-propagation` capability: Two-rule composed variant emission; Default option propagation for shared variants; Composed rules reuse existing declarations; and 7 more. +Requirements for the `compose-css-propagation` capability: Two-rule composed variant emission; Default option propagation for shared variants; Shared-axis compound expansion; and 8 more. ## Requirements @@ -20,8 +20,10 @@ For each shared variant option on each child slot in a composed family, the extr #### Scenario: Specificity contract within composed sublayer -- **WHEN** both the inheritance rule and override rule are emitted for the same variant option -- **THEN** the inheritance rule SHALL have specificity (0,2,0) and the override rule SHALL have specificity (0,3,0) — a structural invariant of the selector shapes +- **WHEN** both the inheritance rule and override rule are emitted for the same shared VARIANT option in `@layer variants.composed` +- **THEN** the inheritance rule SHALL have specificity (0,2,0) and the override rule SHALL have specificity (0,3,0) — a structural invariant of the two selector shapes this rule pair takes +- **AND** the invariant SHALL be scoped to that pair: shared-axis compound expansion emits into `@layer compounds`, where a selector's class count grows with the predicate's arity, and that growth SHALL NOT affect cross-layer precedence +- **AND** within `@layer compounds` the ordering consequence SHALL be understood: flat compound rules all tie at (0,1,0) and resolve by source order, while ancestor forms rank by class count (axes plus exclusions), so two overlapping compounds on one slot can resolve in a different order composed than they do standalone #### Scenario: Override beats inheritance by specificity @@ -42,6 +44,43 @@ When a shared variant axis declares a default option on the Root, the pipeline S - **WHEN** the default inheritance rule above is emitted - **THEN** no `.{child-class}--size-default` selector SHALL appear — the default axis emits exactly one rule, and a defaulted child cannot outrank Root inheritance +### Requirement: Shared-axis compound expansion + +Under the CSS-only transport a shared axis reaches a child slot as a CSS selector, not as a prop — the slot's runtime resolves classes from its OWN props only — so a slot compound whose predicate (its `conditions` map) requires a shared axis cannot activate from its flat rule. For every child-slot compound whose predicate references at least one shared axis, the pipeline SHALL additionally emit an ancestor-form rule inside `@layer compounds`, reusing the compound's already-resolved declarations: the shared half of the predicate SHALL be expressed as Root classes on the ancestor side and the child-only half as the slot's own classes on the descendant side. Emission SHALL be unconditional — a `context: true` family also transports the prop, so the slot's flat rule may activate with the same declarations. Each axis SHALL contribute exactly one POSITIVE piece to its owner's side of the selector: the bare class when it accepts a single value, `:is(...)` over the alternatives when it accepts several (equal summed specificity, and linear in the number of accepted values). Every SHARED axis SHALL additionally contribute one `:not(.{child-class}--{axis}-{option})` per option the slot declares on that axis and the predicate does not accept, so a slot that sets the axis directly keeps its own flat compound. Axis order SHALL follow the predicate's stored (name-sorted) order, alternatives within an axis SHALL run value-order then the default-keyed class last, and exclusions SHALL follow the slot's declaration order after the positive pieces. Option names are interpolated verbatim into selectors, as everywhere else in the emitter — a malformed option name invalidates the rule that carries it. The flat `.{child-class}--compound-{N}` rules, their ordinals, their source order, and the runtime compound config lists SHALL be unchanged: the expansion reads compound data and never rewrites it. + +#### Scenario: Fully shared predicate + +- **WHEN** a family shares `size` and `tone` and a child slot declares `.compound({ size: 'sm', tone: 'loud' }, styles)` +- **THEN** the pipeline SHALL emit `.{root-class}--size-sm.{root-class}--tone-loud .{child-class} { ...styles... }` inside `@layer compounds` + +#### Scenario: Mixed shared and child-only predicate + +- **WHEN** a family shares `size` and a child slot with its own `weight` variant declares `.compound({ size: 'sm', weight: 'bold' }, styles)` +- **THEN** the pipeline SHALL emit `.{root-class}--size-sm .{child-class}.{child-class}--weight-bold { ...styles... }` — the shared axis on the ancestor, the slot's own axis chained on the slot, where its runtime writes that class +- **AND** when the slot declares a default for that child-only axis equal to the required value, that axis SHALL group both classes — `:is(.{child-class}--weight-bold,.{child-class}--weight-default)` — the mirror of the Root-default rule, since an omitted child prop makes the slot's own runtime write the `-default` class + +#### Scenario: Accepted value list groups the axis + +- **WHEN** a child slot declares `.compound({ size: ['sm', 'lg'] }, styles)` on a shared `size` axis +- **THEN** the emitted rule SHALL group that axis as `:is(.{root-class}--size-sm,.{root-class}--size-lg) .{child-class}` — one rule, one declaration block, no per-value selector list + +#### Scenario: Root default activates a shared-axis compound + +- **WHEN** the Root declares `size` defaulting to `sm`, a child slot declares `.compound({ size: 'sm' }, styles)`, and the callsite renders Root WITHOUT a `size` prop +- **THEN** the shared axis SHALL group as `:is(.{root-class}--size-sm,.{root-class}--size-default)`, matching the sidecar class the Root's runtime writes for an omitted prop +- **AND** no `.{child-class}--size-default` selector SHALL be emitted for the shared axis — the suppression invariant carried from the composed default rule keeps a defaulted slot losing to Root inheritance + +#### Scenario: Explicit slot override suppresses the ancestor form + +- **WHEN** a child slot declares its own variant on a shared axis and a callsite sets that prop directly on the slot +- **THEN** the ancestor form SHALL carry `:not(.{child-class}--{axis}-{option})` for every option the slot declares on that axis and the predicate does not accept, so a slot rendering a non-accepted option keeps its own flat compound instead of the Root's +- **AND** a slot that renders an ACCEPTED option, or that only defaults the axis, SHALL still receive the ancestor form — the accepted options and the `-default` class are never excluded + +#### Scenario: Predicate free of shared axes is untouched + +- **WHEN** a child slot's compound predicate references only the slot's own props +- **THEN** only the flat `.{child-class}--compound-{N}` rule SHALL be emitted — the slot's runtime already writes the classes that activate it + ### Requirement: Composed rules reuse existing declarations The extraction pipeline SHALL NOT re-resolve styles for composed variant rules. Composed rules SHALL reuse the already-resolved variant declarations from the per-component extraction pass. @@ -130,7 +169,7 @@ Portal-mounted child slots (e.g., Radix Dialog content, Tooltip content) render #### Scenario: Non-portaled slots in context family use both mechanisms - **WHEN** a composed family has `context: true` and a child slot renders within the Root's DOM subtree -- **THEN** the slot SHALL receive shared variant styling via BOTH CSS descendant selectors AND React context — CSS cascade is primary, context is redundant but harmless +- **THEN** the slot SHALL receive shared variant styling via BOTH CSS descendant selectors AND React context — CSS cascade is primary and carries the whole shared surface for an in-DOM slot: shared variant options through the composed rule pair, and compounds whose predicates reference a shared axis through the ancestor forms in `@layer compounds`. Both activate from the Root's own classes under either transport, so context adds no styling the cascade does not already deliver #### Scenario: Context-free families remain CSS-only diff --git a/openspec/specs/compose-slot-composition/spec.md b/openspec/specs/compose-slot-composition/spec.md index 8ae97463..53a0d645 100644 --- a/openspec/specs/compose-slot-composition/spec.md +++ b/openspec/specs/compose-slot-composition/spec.md @@ -30,7 +30,8 @@ The Root slot SHALL be identified by the exact key `"Root"` (PascalCase, case-se #### Scenario: Lowercase root NOT detected as Root - **WHEN** `compose({ root: RootComp, child: ChildComp }, { shared: {} })` is called -- **THEN** no slot SHALL be treated as Root — `root` is treated as a regular child slot +- **THEN** `root` SHALL NOT be recognized as the Root slot — the key must match `"Root"` exactly, case-sensitively — and it SHALL NOT be demoted to a regular child slot; the call SHALL be rejected +- **AND** the rejection SHALL be a TypeScript error for typed consumers and a thrown error at family construction for untyped ones, in the source forms (`compose`, `composeWithContext`) and the extracted forms (`createComposedFamily`, `createComposedFamilyWithContext`) alike #### Scenario: Type-level Root extraction diff --git a/openspec/specs/dev-stylesheet-management/spec.md b/openspec/specs/dev-stylesheet-management/spec.md index f54c9ea6..11280d0e 100644 --- a/openspec/specs/dev-stylesheet-management/spec.md +++ b/openspec/specs/dev-stylesheet-management/spec.md @@ -40,13 +40,20 @@ In dev mode, component CSS SHALL be delivered via a Constructable StyleSheet man ### Requirement: HMR bridge auto-injected in dev mode -The plugin SHALL inject the HMR bridge via the `transformIndexHtml` hook. The bridge MUST NOT be injected during production builds. +The plugin SHALL deliver the HMR bridge through two dev-mode paths: a `` for it would break the * unconfigured-parity contract in spirit (a tag that does nothing) while - * hiding the defect. Symmetric with `layerDeclaration`. + * hiding the defect. Symmetric with `layerDeclaration`. The bridge has no + * content of its own — it is a `src` reference — so its only guard is the mode. * * Ordering: every descriptor rides the same `head-prepend` bucket, which Vite * serializes in array order (`serializeTags`) and splices in immediately after @@ -21,8 +25,8 @@ import type { HtmlTagDescriptor } from 'vite'; * before the layer declaration and — since built stylesheet links are appended * at `` — before any stylesheet reference. * - * When neither input is set the array is empty, so an unconfigured build emits - * no tag, attribute, or whitespace of its own. + * With neither build-time input set, a production build emits no tag, + * attribute, or whitespace of its own. */ export function buildIndexHtmlTags(ctx: PluginContext): HtmlTagDescriptor[] { const tags: HtmlTagDescriptor[] = []; @@ -46,5 +50,39 @@ export function buildIndexHtmlTags(ctx: PluginContext): HtmlTagDescriptor[] { }); } + // The HMR bridge, dev only (openspec: dev-stylesheet-management, "HMR bridge + // auto-injected in dev mode" / "Bridge absent in prod build"). + // + // Unconditional in dev, and deliberately NOT gated on `storedSheets`: + // index.html can be served before the first analysis completes, and a + // document that missed the tag has no adopted stylesheet for the life of the + // page. The bridge body is generated at request time and dedupes its + // `CSSStyleSheet` behind a `globalThis` key, so an early load is safe — + // it adopts whatever CSS exists and takes updates from + // `virtual:animus/components.js` afterwards. + // + // Position: last in the array, i.e. after the bootstrap and the layer + // declaration in the document. A `type="module"` script is deferred, so it + // cannot delay the inline classic bootstrap; what the ordering buys is that a + // HEAD module script evaluates before the BODY entry module, so the adopted + // stylesheet exists before any component module runs. + // + // This tag is the DOCUMENT half of a dual delivery: transformSource also + // prepends the bridge import to every component-bearing module in dev, which + // is what reaches document-rendering SSR hosts (Remix, React Router) that + // never invoke transformIndexHtml — and what survives any transform-cache + // invalidation of this document's modules. + if (!ctx.isProd) { + tags.push({ + tag: 'script', + attrs: { + type: 'module', + src: BRIDGE_SCRIPT_SRC, + 'data-animus-bridge': '', + }, + injectTo: 'head-prepend', + }); + } + return tags; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 20d0a36d..aad47c32 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -134,6 +134,13 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { name: 'animus-extract', enforce: 'pre', + // Supply the define the system runtime gates its development-only + // diagnostics on — see @animus-ui/system's runtime/is-dev.ts for the + // define/fold story and the expression shape it depends on. + config(_config, env) { + return { define: { __ANIMUS_DEV__: env.command !== 'build' } }; + }, + configureServer(server) { ctx.devServer = server; // System deps may have loaded before the server existed; register diff --git a/packages/vite-plugin/src/transform.ts b/packages/vite-plugin/src/transform.ts index d0e3d1db..d45ba3a6 100644 --- a/packages/vite-plugin/src/transform.ts +++ b/packages/vite-plugin/src/transform.ts @@ -1,15 +1,20 @@ import { contentHash } from '@animus-ui/extract/pipeline'; import { relative, sep } from 'path'; -import { VIRTUAL_BRIDGE_ID } from './constants'; +import { VIRTUAL_BRIDGE_ID, VIRTUAL_PREFIX } from './constants'; import { buildFileEntriesFromCache } from './context'; import type { PluginContext } from './context'; /** * transform: replace builder chains with `createComponent()` calls using - * the pre-built manifest; inject the HMR bridge import once in dev; detect - * files created after buildStart and fold them into the analysis. + * the pre-built manifest; detect files created after buildStart and fold + * them into the analysis. + * + * The HMR bridge is NOT injected here — `transformIndexHtml` delivers it as a + * `` behind. const tags = buildIndexHtmlTags( - contextWith({ appearanceBootstrap: { code: '', cspHash: '' } }) + prodContext({ appearanceBootstrap: { code: '', cspHash: '' } }) ); expect(tags.some((t) => t.tag === 'script')).toBe(false); @@ -139,7 +143,7 @@ describe('Vite injection option: opt-in injection', () => { test('an empty-code artifact with no layer declaration emits nothing at all', () => { const tags = buildIndexHtmlTags( - contextWith({ + prodContext({ appearanceBootstrap: { code: '', cspHash: '' }, layerDeclaration: '', }) @@ -326,7 +330,7 @@ describe('Shape A: the inline artifact mirror tracks AppearanceBootstrapArtifact describe('Vite injection option: absent by default (G4 parity)', () => { test('unconfigured output matches the pre-change descriptor shape', () => { - const tags = buildIndexHtmlTags(contextWith()); + const tags = buildIndexHtmlTags(prodContext()); expect(tags).toEqual([PRE_CHANGE_LAYER_TAG]); // No empty tags, no attribute stubs — the word "bootstrap" cannot appear. @@ -334,7 +338,7 @@ describe('Vite injection option: absent by default (G4 parity)', () => { }); test('unconfigured and no layer declaration still returns an empty array', () => { - const tags = buildIndexHtmlTags(contextWith({ layerDeclaration: '' })); + const tags = buildIndexHtmlTags(prodContext({ layerDeclaration: '' })); expect(tags).toEqual([]); }); @@ -351,12 +355,31 @@ describe('Vite injection option: absent by default (G4 parity)', () => { } expect(hook.order).toBe('pre'); + // Drive the real `configResolved` into BUILD mode first. Without it the + // context is in dev and the builder's dev-only bridge tag rides along, + // which would cost this test its exact-array pin — the one assertion that + // proves an unconfigured build emits no tag, attribute, or whitespace of + // its own. `command: 'build'` is the only field the emptiness depends on. + const configResolved = plugin.configResolved; + if (typeof configResolved !== 'function') { + throw new Error('configResolved must stay in plain-function form'); + } + (configResolved as (config: never) => void).call( + plugin as never, + { + command: 'build', + root: process.cwd(), + base: '/', + } as never + ); + // SCOPE: `ctx.layerDeclaration` is '' until buildStart runs (which needs - // the NAPI engine), so this can only ever observe the EMPTY branch. It - // pins the hook's wiring — object form, `order: 'pre'`, delegation to - // buildIndexHtmlTags — not the layer-present output. The production path - // with a real layer declaration is covered by `vp run verify:integration` - // and by the built-HTML assertions in the consumer verify lanes. + // the NAPI engine), so this can only ever observe the EMPTY branch of the + // bootstrap/layer pair. It pins the hook's wiring — object form, + // `order: 'pre'`, delegation to buildIndexHtmlTags — not the layer-present + // output. The production path with a real layer declaration is covered by + // `vp run verify:integration` and by the built-HTML assertions in the + // consumer verify lanes. const result = ( hook.handler as (...args: never[]) => HtmlTagDescriptor[] ).call(plugin as never); diff --git a/packages/vite-plugin/tests/context-probe.ts b/packages/vite-plugin/tests/context-probe.ts new file mode 100644 index 00000000..757b63de --- /dev/null +++ b/packages/vite-plugin/tests/context-probe.ts @@ -0,0 +1,69 @@ +import type { PluginContext } from '../src/context'; + +/** + * The stand-in `PluginContext` that behavioral tests drive hook bodies with. + * + * `vi.mock` is a no-op in this repo's runner, so a hook is exercised by handing + * it a plain object carrying exactly the state it touches. The fields below are + * the ones every such driver needs — plus the counters the assertions read, + * which live on the returned probe as plain properties the context's own + * closures increment. + * + * A caller adds whatever else its hook reaches for through `extras` (spread + * last, so it may also replace a default), and replaces `ctx.runAnalysis` when + * the analysis has to publish something. + */ +export interface ContextProbe { + ctx: PluginContext; + analyses: number; + extractedInvalidations: number; + /** Standard-level lines (`ctx.info`). */ + infoLines: string[]; + /** Verbose-only lines (`ctx.log`). */ + verboseLines: string[]; +} + +export function makeContextProbe( + rootDir: string, + extras: Record = {} +): ContextProbe { + const probe: ContextProbe = { + ctx: null as unknown as PluginContext, + analyses: 0, + extractedInvalidations: 0, + infoLines: [], + verboseLines: [], + }; + const ctx = { + isProd: false, + verbose: false, + rootDir, + options: {}, + externalPackageDirs: [] as string[], + fileCache: new Map(), + storedManifest: { components: {}, files: {} }, + // The four inputs `virtual:animus/system-props` is generated from. The + // engine republishes them on every analysis whether or not they moved. + storedSystemPropMapJson: '{}', + storedDynamicPropsJson: '{}', + storedTransformsSource: '{}', + system: { groupRegistryJson: '{}' }, + runAnalysis() { + probe.analyses++; + }, + invalidateExtractedModules() { + probe.extractedInvalidations++; + }, + log(msg: string) { + probe.verboseLines.push(msg); + }, + info(msg: string) { + probe.infoLines.push(msg); + }, + warn() {}, + logTimingWaterfall() {}, + ...extras, + }; + probe.ctx = ctx as unknown as PluginContext; + return probe; +} diff --git a/packages/vite-plugin/tests/dev-define.test.ts b/packages/vite-plugin/tests/dev-define.test.ts new file mode 100644 index 00000000..3cc44df9 --- /dev/null +++ b/packages/vite-plugin/tests/dev-define.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; + +import { animusExtract } from '../src/index'; + +import type { ConfigEnv } from 'vite'; + +/** + * The system runtime gates its development-only diagnostics on the + * `__ANIMUS_DEV__` define. The plugin is what supplies it, keyed on Vite's own + * command, so a production build folds those branches away and a dev server + * keeps them. + */ +describe('__ANIMUS_DEV__ define', () => { + const runConfigHook = (command: ConfigEnv['command']) => { + const hook = animusExtract({ system: './src/ds.ts' }).config; + if (typeof hook !== 'function') { + throw new Error('expected a plain function `config` hook'); + } + return hook.call(undefined as never, {}, { command, mode: 'test' }); + }; + + test('dev serve declares the token as dev', () => { + expect(runConfigHook('serve')).toEqual({ + define: { __ANIMUS_DEV__: true }, + }); + }); + + test('build declares the token as not-dev', () => { + expect(runConfigHook('build')).toEqual({ + define: { __ANIMUS_DEV__: false }, + }); + }); +}); diff --git a/packages/vite-plugin/tests/dev-lane/dev-server.test.ts b/packages/vite-plugin/tests/dev-lane/dev-server.test.ts index 2d8f962b..af47a9af 100644 --- a/packages/vite-plugin/tests/dev-lane/dev-server.test.ts +++ b/packages/vite-plugin/tests/dev-lane/dev-server.test.ts @@ -29,10 +29,13 @@ import { componentSource, createDevFixture, INITIAL_BRAND_HEX, + INITIAL_USAGE_STEP, paletteSource, + systemComponentSource, systemSource, themeSource, themeViaPaletteSource, + usageSource, } from './fixture'; import { probeDevLanePrerequisites } from './prerequisites'; import { @@ -53,6 +56,20 @@ vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); const EDITED_BRAND_HEX = '#ff0000'; const REPAIRED_BRAND_HEX = '#00ff00'; const EDITED_BUTTON_PADDING = '24px'; +const RESTYLED_BUTTON_PADDING = '32px'; +/** A `space` scale step the fixture's usage site does not start on. */ +const EDITED_USAGE_STEP = 16; + +/** One `export const = ...;` line of the served system-props module. */ +function exportLine(source: string, name: string): string { + const line = source + .split('\n') + .find((candidate) => candidate.startsWith(`export const ${name} =`)); + if (line === undefined) { + throw new Error(`system-props module has no '${name}' export:\n${source}`); + } + return line; +} const prerequisites = probeDevLanePrerequisites(); @@ -107,6 +124,40 @@ suite( expect(served.componentCss).toContain('8px'); }); + it('the served document carries the HMR bridge, and the URL resolves', async () => { + // The bridge is what creates the adopted stylesheet, so a document + // served without it renders with ZERO component CSS for the life of the + // page. Delivery is per served document (`transformIndexHtml`), which is + // the only place immune to a module-graph invalidation dropping it. + const html = await adapter.indexHtml(); + + expect(html, `served document:\n${html}`).toContain('data-animus-bridge'); + + const src = /]*data-animus-bridge[^>]*>/ + .exec(html)?.[0] + .match(/src="([^"]+)"/)?.[1]; + expect(src, `no src on the bridge tag:\n${html}`).toBeTruthy(); + + // The URL form is only correct if the server can actually serve it — + // this is the assertion that a hand-written `/@id/` prefix earns. + const bridgeModule = await adapter.requestUrl(src!); + expect(bridgeModule).toContain('adoptedStyleSheets'); + expect(bridgeModule).toContain('virtual:animus/components.js'); + + // A head module script evaluates before the body entry module, so the + // adopted stylesheet exists before any component module runs. + expect(html.indexOf('data-animus-bridge')).toBeLessThan( + html.indexOf('/src/main.ts') + ); + + // Transformed component modules carry the bridge import too — the + // module-graph half of delivery. Unconditional per transform, so an + // invalidation of any carrying module re-adds it on re-transform, and + // SSR hosts that never serve index.html still receive it. + const transformed = await adapter.requestSource('src/Button.ts'); + expect(transformed).toContain('hmr-bridge'); + }); + it('editing a component re-analyzes and changes the served component CSS', async () => { const before = await adapter.read(); @@ -136,6 +187,123 @@ suite( expect(after.componentRevision).toBeGreaterThan(before.componentRevision); }); + it('a style-only edit leaves the shared system prop map undelivered', async () => { + const before = await adapter.read(); + // Non-vacuity: there IS a map to re-deliver, so a passing negative below + // means the gate held, not that the module was empty all along. + expect( + before.systemProps, + `system props module:\n${before.systemProps}` + ).toContain(`"${INITIAL_USAGE_STEP}"`); + + // A static padding change: no system-prop usage is added, removed or + // moved, so no utility class can appear or disappear. Every module that + // renders a system prop imports this map — pushing it here would update + // the whole graph for an edit that changed nothing in it. + fixture.write( + 'src/Button.ts', + componentSource('Button', 'button', RESTYLED_BUTTON_PADDING) + ); + + const after = await until( + async () => { + const served = await adapter.read(); + return served.componentCss.includes(RESTYLED_BUTTON_PADDING) + ? served + : false; + }, + { + what: `component CSS picks up padding ${RESTYLED_BUTTON_PADDING}`, + // The ONLY scenario whose write targets the file the previous + // scenario just edited: when that edit's until() passes within the + // watcher's per-path change-throttle window, this write's event is + // dropped outright, so the write must be re-asserted on a slow + // pickup (see UntilOptions.reassert). Every other mutation in the + // lane is either the first event on its path or sits behind the + // reset coalescer's quiescence window, which outlasts the throttle. + reassert: () => + fixture.write( + 'src/Button.ts', + componentSource('Button', 'button', RESTYLED_BUTTON_PADDING) + ), + describe: async () => + `component CSS:\n${(await adapter.read()).componentCss}${renderTrace(adapter)}`, + } + ); + + // The edit landed (component CSS moved), and the map did not. + expect(after.componentRevision).toBeGreaterThan(before.componentRevision); + expect(after.systemProps).toEqual(before.systemProps); + expect(after.systemPropsRevision).toBe(before.systemPropsRevision); + }); + + it('a new system-prop value re-delivers the map', async () => { + const before = await adapter.read(); + + fixture.write('src/Usage.tsx', usageSource(EDITED_USAGE_STEP)); + + // The barrier must watch the systemPropMap EXPORT LINE, not the whole + // module text: dynamicPropConfig carries scale keys, so the bare value + // ("4") already appears in the served module at startup and a + // whole-text match returns before the watcher event is even delivered. + const after = await until( + async () => { + const served = await adapter.read(); + return exportLine(served.systemProps, 'systemPropMap').includes( + `"${EDITED_USAGE_STEP}"` + ) + ? served + : false; + }, + { + what: `system prop map picks up the p=${EDITED_USAGE_STEP} utility`, + describe: async () => + `system props module:\n${(await adapter.read()).systemProps}${renderTrace(adapter)}`, + } + ); + + expect(after.systemPropsRevision).toBeGreaterThan( + before.systemPropsRevision + ); + }); + + it('widening a component system opt-in re-delivers the module', async () => { + const before = await adapter.read(); + + // Adding a group to `.system({ ... })` mints NO new utility class — the + // usage site's padding is untouched — but every prop in the added group + // joins `dynamicPropConfig`. A decision keyed on the prop map alone reads + // this as unchanged and the client keeps a config that cannot render the + // added props, for the life of the server: Vite serves the module's + // cached transform result across full page reloads, so no later event + // repairs it. + fixture.write('src/Box.ts', systemComponentSource(['space', 'surface'])); + + const after = await until( + async () => { + const served = await adapter.read(); + return served.systemProps === before.systemProps ? false : served; + }, + { + what: 'the served system-props module changes after widening the opt-in', + describe: async () => + `system props module:\n${(await adapter.read()).systemProps}${renderTrace(adapter)}`, + } + ); + + // The witness for what this pins: the prop map did NOT move, so only a + // comparison over the whole served module can have delivered the update. + expect(exportLine(after.systemProps, 'systemPropMap')).toEqual( + exportLine(before.systemProps, 'systemPropMap') + ); + expect(exportLine(after.systemProps, 'dynamicPropConfig')).not.toEqual( + exportLine(before.systemProps, 'dynamicPropConfig') + ); + expect(after.systemPropsRevision).toBeGreaterThan( + before.systemPropsRevision + ); + }); + it('editing the theme file the system imports triggers the geological reset', async () => { const before = await adapter.read(); @@ -382,8 +550,8 @@ suite( const cold = await coldAdapter.read(); // Same mode (dev vs dev), same fixture state on disk: a server that - // reached this state through eight incremental edits must serve what a - // server that never saw an edit serves. + // reached this state through every edit above must serve what a server + // that never saw an edit serves. expect(canonicalizeCss(cold.staticCss)).toEqual( canonicalizeCss(incremental.staticCss) ); diff --git a/packages/vite-plugin/tests/dev-lane/fixture.ts b/packages/vite-plugin/tests/dev-lane/fixture.ts index 2c70310c..eabeaf50 100644 --- a/packages/vite-plugin/tests/dev-lane/fixture.ts +++ b/packages/vite-plugin/tests/dev-lane/fixture.ts @@ -20,6 +20,9 @@ import { REPO_ROOT } from './prerequisites'; * src/ds.ts — the system module named in the plugin options * src/Button.ts — a builder-chain component * src/Sentinel.ts— a second component used purely as a watcher barrier + * src/Box.ts — a component opted into the `space` prop group + * src/Usage.tsx — a JSX usage of Box, the only thing that populates the + * shared system prop map * src/main.ts — the html entry's module * index.html — the app document * @@ -115,6 +118,35 @@ export const ${name} = ds `; } +/** + * A component that opts into the `space` group. Static styles alone never + * populate the shared system prop map — only a JSX USAGE of an opted-in prop + * mints a utility class — so the fixture needs this pair to have any prop map + * at all to observe. + */ +export function systemComponentSource(groups: string[] = ['space']): string { + const optIn = groups.map((group) => `${group}: true`).join(', '); + return `import { ds } from './ds'; + +export const Box = ds + .styles({ bg: 'primary' }) + .system({ ${optIn} }) + .asElement('div'); +`; +} + +/** + * The usage site, at one step of the theme's `space` scale. Nothing imports it: + * the plugin discovers it by walking the project, and the dev server never has + * to transform JSX (the fixture has no JSX runtime installed). + */ +export function usageSource(paddingStep: number): string { + return `import { Box } from './Box'; + +export const App = () => ; +`; +} + const INDEX_HTML = ` @@ -148,6 +180,8 @@ export interface DevFixture { export const INITIAL_BRAND_HEX = '#3b82f6'; export const INITIAL_BUTTON_PADDING = '8px'; +/** The `space` scale step `src/Usage.tsx` starts on. */ +export const INITIAL_USAGE_STEP = 4; export function createDevFixture(): DevFixture { // realpath: macOS hands back /var/... while the watcher reports /private/var, @@ -185,6 +219,8 @@ export function createDevFixture(): DevFixture { write('src/ds.ts', systemSource('initial')); write('src/Button.ts', componentSource('Button', 'button', '8px')); write('src/Sentinel.ts', componentSource('Sentinel', 'aside', '1px')); + write('src/Box.ts', systemComponentSource()); + write('src/Usage.tsx', usageSource(INITIAL_USAGE_STEP)); write('src/main.ts', MAIN_SOURCE); return { diff --git a/packages/vite-plugin/tests/dev-lane/scenario.ts b/packages/vite-plugin/tests/dev-lane/scenario.ts index cd64fa72..a129b6e7 100644 --- a/packages/vite-plugin/tests/dev-lane/scenario.ts +++ b/packages/vite-plugin/tests/dev-lane/scenario.ts @@ -12,12 +12,14 @@ * it gives up. */ -/** The two stylesheets the plugin serves in dev, plus their bundler revisions. */ +/** The modules the plugin serves in dev, plus their bundler revisions. */ export interface DevArtifacts { /** `virtual:animus/styles.css` — variable block + global layer. */ staticCss: string; /** `virtual:animus/components.js` — the adopted component stylesheet. */ componentCss: string; + /** `virtual:animus/system-props` — the shared prop map module's source. */ + systemProps: string; /** * Monotonic invalidation stamp for the static module. Bumps whenever the * bundler invalidates it, which is how a geological reset is observed @@ -26,6 +28,12 @@ export interface DevArtifacts { staticRevision: number; /** Monotonic invalidation stamp for the component module. */ componentRevision: number; + /** + * Monotonic invalidation stamp for the shared prop map module. Every module + * that renders a system prop imports it, so this stamp is the observable + * blast radius of an edit. + */ + systemPropsRevision: number; } /** One dev server under test. Implemented per bundler. */ @@ -42,6 +50,18 @@ export interface DevServerAdapter { * discovered. */ requestSource(projectRelativePath: string): Promise; + /** + * Request an arbitrary browser URL through the server's own pipeline — + * including the non-file URLs a virtual module is served under. + */ + requestUrl(url: string): Promise; + /** + * The document a browser receives for `/`: the fixture's `index.html` after + * every `transformIndexHtml` hook (the plugin's included) has run. This is + * the only artifact that carries delivery decisions made per SERVED + * DOCUMENT rather than per module. + */ + indexHtml(): Promise; /** Tear the server down. Safe to call when `start` never ran. */ close(): Promise; /** @@ -66,6 +86,23 @@ export interface UntilOptions { what: string; timeoutMs?: number; everyMs?: number; + /** + * Re-issue the mutation the probe is waiting on. Called every + * `REASSERT_EVERY_POLLS` polls (~1s) while the probe still reports + * "not yet". + * + * Why it exists: the dev server's vendored chokidar throttles change events + * per path (50ms in `_emit`, drop — NOT redeliver), so a write landing + * <50ms after the previous change event on the SAME path produces no event + * at all, and nothing downstream can ever observe it (observed on CI where + * back-to-back scenarios edit one file <50ms apart; reproduced locally + * with two same-file writes ~30ms apart). Any wait on such a write must + * re-assert it: the rewrite is idempotent at the plugin layer — when the + * original event was delivered it hash-skips as unchanged — and when the + * event was throttled away the rewrite, now outside the window, emits the + * event that carries the mutation in. + */ + reassert?: () => void; /** * Rendered into the failure message. Called only on timeout, so it can read * the server again and report the state actually observed last. @@ -75,6 +112,8 @@ export interface UntilOptions { const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_EVERY_MS = 25; +/** Cadence of `UntilOptions.reassert`: ~1s at the 25ms poll interval. */ +const REASSERT_EVERY_POLLS = 40; /** * Poll `probe` until it yields a value, then return it. `false` means @@ -97,6 +136,9 @@ export async function until( const last = await probe(); attempts += 1; if (last !== false) return last; + if (options.reassert && attempts % REASSERT_EVERY_POLLS === 0) { + options.reassert(); + } const elapsed = Date.now() - startedAt; if (elapsed >= timeoutMs) { @@ -123,21 +165,13 @@ export async function until( * has landed the earlier write has been delivered too, so the negative * assertion is a single read rather than a race with a sleep. * - * One watcher semantic the barrier must absorb: chokidar throttles change - * events per path (50ms in its nodefs `_emit`, drop — NOT redeliver), so a - * sentinel write landing hot on the heels of the previous sentinel event - * produces no event at all (observed on CI where consecutive scenarios run - * <50ms apart; reproduced locally with two same-file writes ~30ms apart). - * The barrier therefore re-asserts the SAME marker on a slow pickup. The - * rewrite is idempotent at the plugin layer: when the original event was - * delivered the rewrite hash-skips as unchanged; when it was throttled away - * the rewrite — now outside the window — emits the event that carries the - * marker in. Drainage still holds: every pre-barrier mutation event precedes - * the first sentinel write, so observing any sentinel write proves them - * delivered. + * One watcher semantic the barrier must absorb: a sentinel write landing hot + * on the heels of the previous sentinel event can be throttled away entirely + * (see `UntilOptions.reassert` for the mechanism), so the barrier re-asserts + * the SAME marker on a slow pickup. Drainage still holds: every pre-barrier + * mutation event precedes the first sentinel write, so observing any sentinel + * write proves them delivered. */ -const REASSERT_EVERY_POLLS = 40; // ~1s at the 25ms poll cadence - export function createWatcherBarrier( writeSentinel: (marker: string) => void, read: () => Promise, @@ -148,15 +182,11 @@ export function createWatcherBarrier( counter += 1; const marker = `${100 + counter}px`; writeSentinel(marker); - let polls = 0; await until( - async () => { - polls += 1; - if (polls % REASSERT_EVERY_POLLS === 0) writeSentinel(marker); - return (await read()).componentCss.includes(marker) || false; - }, + async () => (await read()).componentCss.includes(marker) || false, { what: `watcher barrier #${counter} (sentinel padding ${marker})`, + reassert: () => writeSentinel(marker), describe: async () => `sentinel ${marker} absent from component CSS:\n${(await read()).componentCss}${describeExtra?.() ?? ''}`, } diff --git a/packages/vite-plugin/tests/dev-lane/vite-adapter.ts b/packages/vite-plugin/tests/dev-lane/vite-adapter.ts index 93bd48b9..c211f7c3 100644 --- a/packages/vite-plugin/tests/dev-lane/vite-adapter.ts +++ b/packages/vite-plugin/tests/dev-lane/vite-adapter.ts @@ -1,4 +1,6 @@ +import { readFileSync } from 'fs'; import { createServer as createNetServer } from 'net'; +import { join } from 'path'; import { createServer } from 'vite-plus'; import { animusExtract } from '../../src/index'; @@ -17,6 +19,7 @@ import type { DevArtifacts, DevServerAdapter } from './scenario'; /** Resolved ids the plugin serves — the `\0` prefix is Vite's virtual marker. */ const STATIC_MODULE_ID = '\0virtual:animus/styles.css'; const COMPONENT_MODULE_ID = '\0virtual:animus/components.js'; +const SYSTEM_PROPS_MODULE_ID = '\0virtual:animus/system-props'; /** Unwrap `const __vite__css = "..."` from Vite's dev CSS module wrapper. */ function decodeStaticCss(code: string): string { @@ -54,6 +57,7 @@ function reserveHmrPort(): Promise { export function createViteDevAdapter(): DevServerAdapter { // eslint-disable-next-line @typescript-eslint/no-explicit-any let server: any = null; + let projectRoot = ''; // Bounded evidence trail for timeout forensics: raw chokidar events plus // everything the plugin and Vite log. The server runs with a capturing @@ -92,6 +96,7 @@ export function createViteDevAdapter(): DevServerAdapter { name: 'vite', async start(root: string): Promise { + projectRoot = root; // The config type is erased on purpose. The plugin is typed against its // `vite` peer while vite-plus vendors its own structurally identical copy // of those types; comparing the two inline configs is a nominal mismatch @@ -137,11 +142,18 @@ export function createViteDevAdapter(): DevServerAdapter { COMPONENT_MODULE_ID, decodeComponentCss ); + // The prop map module is JS, not CSS — served verbatim. + const systemPropsModule = await readModule( + SYSTEM_PROPS_MODULE_ID, + (code) => code + ); return { staticCss: staticModule.css, componentCss: componentModule.css, + systemProps: systemPropsModule.css, staticRevision: staticModule.revision, componentRevision: componentModule.revision, + systemPropsRevision: systemPropsModule.revision, }; }, @@ -152,6 +164,29 @@ export function createViteDevAdapter(): DevServerAdapter { return result?.code ?? ''; }, + async requestUrl(url: string): Promise { + // Vite's transform middleware normalizes a browser URL via `unwrapId` + // before serving it (see BRIDGE_SCRIPT_SRC in src/constants.ts); + // `transformRequest` does not, so requesting the raw URL would assert on + // a path no browser takes. Mirrored here, and nowhere else in the lane, + // because this is the only URL that is not a plain file path. + const stripped = url.startsWith('/@id/') + ? url.slice('/@id/'.length) + : url; + const id = stripped.replace('__x00__', '\0'); + const result = await server.environments.client.transformRequest(id); + return result?.code ?? ''; + }, + + async indexHtml(): Promise { + // `server.transformIndexHtml` is the same call the dev HTML middleware + // makes for a `/` request: it runs the pre/normal/post hook chain over + // the file on disk, so the returned string is byte-for-byte what a + // browser is handed. + const raw = readFileSync(join(projectRoot, 'index.html'), 'utf-8'); + return server.transformIndexHtml('/index.html', raw, '/'); + }, + async close(): Promise { if (!server) return; const closing = server; diff --git a/packages/vite-plugin/tests/hmr-bridge-injection.test.ts b/packages/vite-plugin/tests/hmr-bridge-injection.test.ts new file mode 100644 index 00000000..adda1503 --- /dev/null +++ b/packages/vite-plugin/tests/hmr-bridge-injection.test.ts @@ -0,0 +1,185 @@ +import { runInNewContext } from 'node:vm'; +import { describe, expect, test } from 'vitest'; + +import { + BRIDGE_SCRIPT_SRC, + RESOLVED_BRIDGE_ID, + VIRTUAL_BRIDGE_ID, +} from '../src/constants'; +import { animusExtract } from '../src/index'; +import { buildIndexHtmlTags } from '../src/index-html'; +import { loadVirtualModule } from '../src/virtual-modules'; +import { contextWith, LAYER_DECLARATION } from './index-html-context'; + +import type { HtmlTagDescriptor } from 'vite'; + +/** + * HMR bridge delivery (openspec: dev-stylesheet-management, "HMR bridge + * auto-injected in dev mode"): TWO dev paths, none in production. The + * `transformIndexHtml` tag fires once per SERVED DOCUMENT and never on HMR, + * so document apps get delivery that no server-lifetime flag can spend and no + * transform-cache invalidation can strand (the deps-optimizer's + * `invalidateAll()` discards transform results with no file change behind + * them). The transform-time import prepended to every component-bearing + * module is the MODULE-GRAPH path: unconditional per transform, so a + * re-transform re-adds it — and it is the only path that reaches + * document-rendering SSR hosts (Remix, React Router), which never invoke + * `transformIndexHtml`. That path is covered in transform-source.test.ts; + * the bridge body's server-side no-op is pinned below. + * + * The bridge module tolerates loading before any analysis has completed: its + * body is generated at request time from whatever `resolvedComponentCss` holds + * (empty string included) and it dedupes the `CSSStyleSheet` behind a + * `globalThis` key, so a document served ahead of the first analysis simply + * adopts an empty sheet and picks up content on the next + * `virtual:animus/components.js` update. Hence: no `storedSheets` gate. + */ + +const BRIDGE_TAG: HtmlTagDescriptor = { + tag: 'script', + attrs: { + type: 'module', + src: BRIDGE_SCRIPT_SRC, + 'data-animus-bridge': '', + }, + injectTo: 'head-prepend', +}; + +describe('bridge delivery via transformIndexHtml', () => { + test('dev emits a module script on every served document, before any analysis', () => { + // index.html can be served before the first analysis completes; a document + // without the bridge has no adopted stylesheet for the life of the page. + // The hook holds no per-server state, so re-serving must keep emitting. + const ctx = contextWith({ isProd: false }); + expect(ctx.storedSheets).toBeNull(); + + for (const tags of [ + buildIndexHtmlTags(ctx), + buildIndexHtmlTags(ctx), + buildIndexHtmlTags(ctx), + ]) { + expect(tags).toContainEqual(BRIDGE_TAG); + } + }); + + test('the src is the browser-addressable form of the virtual id', () => { + // Why `/@id/` and why the UNPREFIXED specifier: see BRIDGE_SCRIPT_SRC in + // src/constants.ts. That the resulting URL is actually servable is proven + // against a real dev server in tests/dev-lane/dev-server.test.ts. + expect(BRIDGE_SCRIPT_SRC).toBe(`/@id/${VIRTUAL_BRIDGE_ID}`); + }); + + test('production emits no bridge tag at all', () => { + const tags = buildIndexHtmlTags( + contextWith({ isProd: true, layerDeclaration: LAYER_DECLARATION }) + ); + + expect( + tags.some((tag) => tag.attrs?.['data-animus-bridge'] !== undefined) + ).toBe(false); + expect(JSON.stringify(tags)).not.toContain('hmr-bridge'); + expect(JSON.stringify(tags)).not.toContain('/@id/'); + }); + + test('the bridge rides head-prepend, after the bootstrap and layer tags', () => { + // All three share the `head-prepend` bucket, which Vite serializes in array + // order, so array order is document order. A `type="module"` script is + // deferred, so its position cannot delay the inline classic bootstrap; the + // guarantee that matters is that a HEAD module script evaluates before the + // BODY entry module, i.e. before any component module runs. + const tags = buildIndexHtmlTags( + contextWith({ + isProd: false, + appearanceBootstrap: { code: 'void 0;', cspHash: 'sha256-x' }, + layerDeclaration: LAYER_DECLARATION, + }) + ); + + expect(tags.every((tag) => tag.injectTo === 'head-prepend')).toBe(true); + expect(tags.map((tag) => tag.tag)).toEqual(['script', 'style', 'script']); + expect(tags.at(-1)).toEqual(BRIDGE_TAG); + }); +}); + +describe('the wired hook delivers the bridge', () => { + test('the real plugin hook returns the bridge tag in its dev default state', () => { + const plugin = animusExtract({ system: './ds.ts' }); + const hook = plugin.transformIndexHtml; + + if (typeof hook !== 'object' || hook === null || !('handler' in hook)) { + throw new Error( + 'transformIndexHtml must stay in object-with-handler form' + ); + } + + // `isProd` is false and `layerDeclaration` is '' until `configResolved` / + // `buildStart` run, so this observes exactly the bridge branch. + const result = ( + hook.handler as (...args: never[]) => HtmlTagDescriptor[] + ).call(plugin as never); + + expect(result).toEqual([BRIDGE_TAG]); + }); +}); + +describe('the bridge module is server-safe', () => { + test('evaluating the bridge body without a document is a no-op, not a throw', () => { + // SSR hosts reach the bridge through the import prepended to transformed + // component modules, so the module body evaluates on the server too. The + // ESM shell is swapped for scriptable equivalents; the DOM logic under + // test is byte-identical. + const source = loadVirtualModule( + { + isProd: false, + lcssTargets: undefined, + warn: () => {}, + options: { system: './ds.ts' }, + } as never, + RESOLVED_BRIDGE_ID + ); + if (!source) throw new Error('bridge module did not resolve'); + + const scriptable = source + .replace(/^import css from .*$/m, "const css = '';") + .replaceAll('import.meta.hot', 'undefined'); + + const context: Record = {}; + context.globalThis = context; + expect(() => runInNewContext(scriptable, context)).not.toThrow(); + // No sheet was created and no global key was written. + expect(Object.keys(context)).toEqual(['globalThis']); + }); + + test('the hot-accept callback is also a no-op without a document', () => { + // The server module runner has a hot channel of its own: any style edit + // dispatches the accept callback server-side, where `sheet` is null and + // the