Skip to content

test: run DOM suites against headless Chromium - #256

Merged
tenphi merged 1 commit into
mainfrom
test/headless-chromium-env
Aug 9, 2026
Merged

test: run DOM suites against headless Chromium#256
tenphi merged 1 commit into
mainfrom
test/headless-chromium-env

Conversation

@tenphi

@tenphi tenphi commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Adds a headless Chromium test environment so Tasty's CSS is verified against a real CSS engine, and fixes one bug that environment immediately surfaced.

Why

Tasty compiles to CSS, and only a CSS engine can tell you whether that CSS is valid. jsdom and happy-dom reject @container, @starting-style, @property, @function, and CSS nesting outright — which had quietly hollowed out the suites that depended on them:

  • 53 of the 54 snapshots in advanced-states.test.tsx were "". jsdom rejected every @media / @parent / @root rule at insertRule, so the snapshots asserted nothing at all.
  • Six tests were empty it() bodies and two were it.skip, all annotated "jsdom limitation" — @container and @starting-style had no coverage whatsoever.
  • Source comments documented the gaps: "jsdom/happy-dom CSSOM rejects @function at insertRule", "happy-dom's CSSKeyframesRule.cssText omits the steps".

What changed

Two Vitest projects (vitest.config.ts):

Project Environment Covers
node plain Node, no DOM parser, style handlers, pipeline, SSR strings, Babel extractor
browser headless Chromium via Playwright anything touching document, rendering React, or asserting on parsed CSS

Environment is decided by an explicit BROWSER_TESTS list, so the @vitest-environment pragmas are gone. jsdom and happy-dom are removed as dependencies. A DOM test left in the node project fails loudly with document is not defined, so misplacement is cheap.

Runtime went 11.3s → 4.6s, mostly because jsdom's 54s of environment setup is gone.

Coverage that would otherwise have been lost in the move. Three suites passed for the wrong reason once Chromium replaced happy-dom, and were fixed rather than left green:

  • Two @property tests relied on happy-dom rejecting the rule, which Chromium accepts — they'd have passed vacuously. They now stub insertRule via simulateNoAtPropertySupport(), which also can't silently lapse the day happy-dom adds support.
  • useKeyframes only asserted rule content in text mode. Chromium round-trips CSSKeyframesRule.cssText, so it now asserts in both (the helper needed brace-matching — Chromium serializes keyframes multi-line).
  • The @function client CSSOM path had no coverage at all; added.

New src/applied-styles.test.tsx closes the largest remaining gap: nothing verified the generated CSS applies. Every suite asserted on CSS text, and the only four getComputedStyle uses were weak negatives (not.toBe('red')) that pass even when no CSS applies. 18 tests now assert on computed values — tokens resolving through @property initial values, specificity doubling beating a later equally-specific rule, state maps switching on mod toggle, container and media queries actually matching, shadow DOM isolation, CSSOM vs text injection. Each has a contrasting counterpart, so none can pass vacuously. The four weak assertions in value-mods.test.tsx became positive ones.

Bug fix included

gridColumns / gridRows only expanded real numbers, so gridColumns: { '': '3', '@media(w <= 600px)': '1' } emitted grid-template-columns: 3 — invalid CSS browsers drop silently. Every value in a state map is a string, so responsive grids were the common way to hit it.

The root cause was in createStyle, which skipped converters entirely for string values. Changing that gate meant gridTemplate also had to fall back per segment, or auto / 1fr would have collapsed to /. Verified the edges against the engine rather than by assumption: CSS.supports('grid-template-columns', ...) confirms 3 is invalid but 0 is valid (a zero-length track), so digit strings ≥ 1 expand as counts and '0' stays a length. Negative counts used to throw inside String.repeat.

Covered by src/styles/grid.test.ts and carries a patch changeset.

Benchmarks

Adding the browser project silently broke pnpm bench — it ran both projects concurrently, and the browser competing for CPU roughly halved the Node numbers (renderStyles read 33k concurrent vs 60k isolated). Split into pnpm bench (Node, the documented numbers) and pnpm bench:browser.

Re-measured on the same M1 Max / Node 22 the README claims, two isolated runs agreeing within ~10%. parseStyle matched; the pipeline figures were optimistic:

Operation Was Now
renderStyles 5 flat props (cold) ~72,000 ~60,000
renderStyles state map (cold) ~22,000 ~18,500
renderStyles (cached) ~7,200,000 ~5,800,000
parseStateKey simple (cold) ~1,200,000 ~790,000
parseStateKey complex (cold) ~190,000 ~140,000

Also corrected "cache multipliers are 30x–100x" — the real range is 10x–300x.

Reviewer notes

  • Read the snapshot diff as the headline. 53 previously-empty snapshots now hold real CSS; that diff is the coverage this PR buys.
  • isDevEnv() can't work in a browser. There's no process global, so NODE_ENV is unreachable and only localStorage.TASTY_DEBUG flips it — the vi.stubEnv('NODE_ENV', …) tests were only ever exercising the Node path. They now use enableDevWarnings() (src/test/dev-env.ts). Whether shipped code should be able to warn in browsers is a separate call.
  • Contributors need pnpm test:setup once to download Chromium. Documented in CONTRIBUTING.md; CI installs and caches it on the lockfile hash.
  • One commit because the grid fix and the migration both rewrite the same snapshot file, so neither splits cleanly.
  • Benchmark numbers came from a machine that had been running builds — worth confirming on a quiet one before release.
  • Unrelated, but noticed: size-limit for main is at 56.72 kB against a 57 kB limit. Thin headroom.

Verification

pnpm build, lint, format:check, typecheck, knip, test, size all pass locally. 68 files / 1974 tests green; browser suite stable across repeated runs.

🤖 Generated with Claude Code

Tasty compiles to CSS, and only a real CSS engine can tell you whether that
CSS is valid. jsdom and happy-dom reject @container, @starting-style,
@Property, @function, and CSS nesting outright, which had quietly hollowed
out the suites that depended on them: 53 of the 54 snapshots in
advanced-states.test.tsx were empty strings asserting nothing, six tests were
empty `it()` bodies, and two were `it.skip`.

Split Vitest into two projects — `node` for pure logic (parser, style
handlers, pipeline, SSR strings, Babel extractor) and `browser` for anything
touching `document` or asserting on CSS the engine parsed. jsdom and
happy-dom are dropped entirely. Total runtime went 11.3s to 4.6s, mostly
because jsdom's 54s of environment setup is gone.

Three places would otherwise have lost coverage in the move:

- Two @Property tests relied on happy-dom rejecting the rule natively, which
  Chromium accepts. They now stub insertRule explicitly, so the branch stays
  covered and cannot silently lapse if happy-dom adds support.
- useKeyframes only asserted rule content in text mode; Chromium round-trips
  CSSKeyframesRule.cssText, so it asserts in both.
- The @function client CSSOM path had no coverage at all.

Also fixes a bug the new environment surfaced: gridColumns/gridRows only
expanded real numbers, so `gridColumns: { '': '3' }` emitted
`grid-template-columns: 3` — invalid CSS browsers drop silently. The cause
was createStyle skipping converters for string values. Changing that gate
meant gridTemplate had to fall back per segment, or `auto / 1fr` would have
collapsed to `/`. Carries a changeset; snapshots for it live in the same file
as the migration, hence one commit.

New src/applied-styles.test.tsx closes the largest remaining gap: nothing
verified the generated CSS actually applies. It asserts on getComputedStyle —
tokens resolving through @Property initial values, specificity doubling
beating a later rule, state maps switching values, container and media
queries matching, shadow DOM isolation. The four weak `not.toBe(...)`
assertions in value-mods.test.tsx became positive ones.

CI installs Chromium via Playwright, cached on the lockfile hash.
`pnpm bench` now targets the node project only — running both concurrently
let the browser compete for CPU and halved the pipeline numbers the README
quotes, which are re-measured here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.e034968.

pnpm add @tenphi/tasty@0.0.0-snapshot.e034968

@tenphi
tenphi merged commit b39e27f into main Aug 9, 2026
6 of 7 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant