Testing
This document describes the test framework, conventions, and CI integration for the XMachines JS monorepo.
Test Framework and Setup
The monorepo uses Vitest ^5.0.1 as its test framework, with @vitest/coverage-v8 for coverage reporting and @vitest/browser-playwright (Playwright/Chromium) for browser-mode tests.
All packages extend the shared Vitest configuration helper defineXmVitestConfig (from @xmachines/shared/vitest) which automatically applies:
@xmachines/*source aliases so imports resolve to source during test runs — or, in the dist run, to the build output (see Tests on the build output)@xmachines/shared/vitest-setup— extends Vitest matchers with@testing-library/jest-dom@xmachines/shared/vitest-node-setup— enforces Node.js ≥ 24 on non-browser projects
Auto-injected setup files:
| File | When injected | Purpose |
|---|---|---|
packages/shared/config/vitest.node.setup.ts | All non-browser projects | Validates Node.js ≥ 24 runtime; throws if wrong runtime |
packages/shared/config/vitest.setup.ts | All projects | Imports @testing-library/jest-dom/vitest matchers |
vitest.setup.ts is also the one file that @xmachines/shared/tsconfig-test names in files, so its import puts the matcher TYPES in every test program. Each package overrides include, and files survives that.
The jest-dom patch
patches/@testing-library__jest-dom@7.0.1.patch moves the matcher declaration of the library from Assertion to Matchers. The library declares this:
declare module "vitest" { interface Assertion<T = any> extends TestingLibraryMatchers<any, T> {}}Vitest 5 declares interface Assertion<R extends void | Promise<void> = void, T = unknown>. A module augmentation merges only when the type parameters are IDENTICAL, and TypeScript reports nothing when they differ — it simply does not apply. Every matcher therefore left the type of expect(...) while the runtime kept working. Matchers is the interface that vitest publishes for an extension, and Assertion extends it, so the patched declaration reaches every assertion and restates no interface whose shape vitest owns.
Delete the patch, its patchedDependencies entry in pnpm-workspace.yaml, and tests/jest-dom-augmentation.test.ts on the day the library ships a declaration that vitest 5 accepts. See testing-library/jest-dom#738.
The vitest mocker patch
patches/@vitest__mocker@5.0.1.patch takes the configureServer hook off vitest:mocks:interceptor when the caller passes registerWebSocketEvents: false, which is how @vitest/browser calls it.
Vitest returns that plugin from the applyToEnvironment of another plugin, and Vite 8 ignores a configureServer that it finds there. Vite reports the fact one time for each environment, so a run of 29 projects wrote 29 lines, in the test job and in the test:browser job both. The hook returns at once for registerWebSocketEvents: false, so the omission changes no behaviour.
Delete the patch and its patchedDependencies entry when vitest ships the fix. See vitest-dev/vitest#11276.
Before running any tests, ensure all dependencies are installed:
pnpm install --frozen-lockfileRunning Tests
Full test suite (Node environments)
pnpm testRuns vitest run across every project that the root vitest.config.ts collects — 38 of them today: one for each package, one for each demo that holds node tests, and infrastructure for the repository tests in tests/. A node project uses the forks pool (up to 4 workers) and reuses a worker across test files — see Isolation. A jsdom project uses the vmThreads pool — see Test environments.
The root config finds those projects by glob, so a new package or demo joins the run with no edit. Each test file must belong to one project: a package config that reaches into its own examples/ collects the demo tests that the demo config collects already, and the run then executes them two times, under two different environments. tests/project-collection-overlap.test.ts asks Vitest for the whole collection and fails on any file that two projects claim.
Watch mode (development)
pnpm run test:watchRuns vitest in interactive watch mode. Re-runs affected tests on file change.
Browser tests (Playwright / Chromium)
pnpm run test:browserRuns vitest run --config vitest.browser.config.ts. Launches all browser-mode projects using headless Chromium via Playwright. Includes both package-level browser unit tests and full demo-app integration flows. The browser global setup (vitest.browser.global-setup.ts) raises process.setMaxListeners to 32 before workers spawn to prevent false-positive MaxListenersExceededWarning with multiple parallel browser projects.
Tests on the build output
pnpm run buildpnpm run test:dist # the node suitespnpm run test:browser:dist # the browser suitespnpm test runs every suite on the SOURCE of each package. It needs no build, and it cannot see a defect of the build. pnpm run test:dist runs the same test files on the BUILD OUTPUT, as a consumer installs it. The 6.0.0 release shows why both runs are necessary. @xmachines/play-vue and @xmachines/play-svelte shipped compiled for server-side rendering, and neither rendered in a browser. Every source suite was green. The node dist run fails 14 test files on that output, and the browser dist run fails the Vue suites.
The two scripts set XM_TEST_TARGET=dist, and the config helpers of @xmachines/shared/vite-aliases read it. No package config holds a copy of the dist setup. The variable changes two things:
- A specifier. The alias map drops each published package.
@xmachines/play-xstate/routingthen resolves through theexportsmap of the package todist/with-routing.js. Thesourcecondition is not set either, so the resolution is the one that a consumer gets. - A path into
src/. A test imports the module under test by path, as../src/define-player.js, and that import reaches no exports map. ThexmBuildOutputplugin moves it todist/define-player.js, which is the file that the package entry imports. The test and the package then share ONE module, and avi.mockof thesrc/path replaces the module of the build. The build keeps the module structure, so each source file has one counterpart.
The private packages — the demos and the examples/shared fixtures — reach no consumer, and the build does not compile them for one. Both runs read their source, and that source imports the packages from dist/.
The plugin fails an import when the build output holds no counterpart. The import then names both files. A fallback to the source would hide the defect that the dist run exists to find. The plugin also fails the run when anything loads a src/ file of a published package by another route.
Keep one file set. Both runs collect the same files. A test that cannot run on the build output imports an internal module that the package does not ship. Import that module through the public entry of the package. If that is not possible, exclude the test from the dist run in its project config, and write the reason beside the exclusion. No test is excluded today.
The proof. packages/play-xstate/test/test-target.test.ts (node) and packages/play-dom/test/browser/test-target.browser.test.ts (browser) run in both runs. Each reads the target with inject("xmTestTarget"), and it asserts which file each kind of import loads. import.meta.resolve cannot answer that question, because it asks the resolver of Node. A stack trace cannot answer it either, because Vite applies the source maps of dist/ and names src/ in both runs.
Run pnpm run build first. The dist run reads whatever dist/ holds, and it does not build. A change to src/ reaches the dist run only through a new build.
Coverage stays on the source run. The build output carries none of the lines that the gate measures.
With coverage
pnpm run test:coverageProduces coverage output in text, html, and json-summary formats.
pnpm run test:browser:coverage # browser suite, with coverageRoot-level coverage thresholds (regression gate):
| Metric | Threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Statements | 80% |
| Branches | 75% |
Individual packages may enforce higher per-package thresholds in their own vitest.config.ts.
Verify TypeScript test graph compiles
pnpm run test:buildRuns tsc --build tsconfig.test.json --force. This validates that all test TypeScript files across the monorepo type-check correctly without running the tests themselves. Also compiles the .typecheck.ts files of the test/ directories.
This is the command that answers “does this tree type-check?”. Use it rather than pnpm run build for that question. tsc --build skips a project whose dependencies have unchanged .d.ts files, so an error inside the source of a library that changes no exported declaration can leave every consumer of that library up to date and never be reported — and consumers here compile library source, through the source export condition. --force costs about a second and takes that decision away from tsc; tests/typecheck-staleness.test.ts holds it, and the two other properties the complete check rests on, in place: no project of the gate is composite, and the CI job starts with no build state on disk. The job gets that second property by DELETING every *.tsbuildinfo before it runs the gate. It does not inherit it: most of the caching of the pipeline arrives through the included node component, and no test in this repository can read that component’s cache paths, so a claim about them would be prose that nothing verifies.
Running tests for a single package
From the monorepo root:
pnpm --filter @xmachines/play testpnpm --filter @xmachines/play-react run test:coverageOr from within a package directory, run pnpm exec vitest run directly after building the workspace.
Test File Organization
Tests live in a separate test/ directory within each package — never co-located with source files.
packages/<name>/├── src/│ ├── *.ts│ └── *.typecheck.ts # Compile-time-only type assertions (not Vitest tests)└── test/ ├── *.spec.ts # Protocol enforcement and type-safety tests ├── *.test.ts # Unit, integration, and behavioral tests ├── browser/ │ └── *.browser.test.ts(x) # Playwright browser environment tests ├── fixtures/ # Shared test actors, helpers, mock data └── tsconfig.json # Test-specific tsconfig (extends shared/tsconfig-test)File naming conventions
| Extension | Purpose |
|---|---|
.spec.ts | Protocol enforcement tests; compile-time type safety tests; used in core packages (play, play-actor, play-atom, play-xstate) |
.test.ts | Unit, integration, and behavioral tests; used across all packages |
.browser.test.ts / .browser.test.tsx | Playwright browser environment tests; excluded from standard vitest.config.ts, only run via vitest.browser.config.ts |
.typecheck.ts (in src/) | Compile-time-only type assertion files; not Vitest test files; validated by pnpm run test:build |
router-bridge-contract.ts | Shared contract test runner; not a test file itself; imported by adapter test files |
Test environments
| Environment | Packages |
|---|---|
node | Core logic: @xmachines/play, play-actor, play-atom, play-xstate, play-router, router adapters |
jsdom | UI renderers: @xmachines/play-react, play-vue, play-solid, play-svelte, play-dom |
| Browser (Playwright/Chromium) | Browser-specific and E2E demo tests |
defineXmVitestConfig gives a jsdom project the vmThreads pool. The forks pool builds one jsdom for each test file, and a jsdom costs about 0.4 s. The vmThreads pool gives each file its own VM context in a worker that keeps the environment, so a worker builds one jsdom for all the files that it runs. Each file still gets a fresh module registry and fresh globals. The full pnpm test run takes 94 s with vmThreads and 160 s with forks, and the environment share of the tracked time drops from 42% to 14%.
A project that declares its own pool keeps it.
Isolation
defineXmVitestConfig gives a node project isolate: false, and it keeps isolation for a jsdom project, for a browser project, and for a project that declares environmentMatchGlobs.
Isolation gives each test FILE its own worker: Vitest spawns a process, builds the environment, and evaluates the module graph one more time. That costs about 110 ms for each file, and the full pnpm test run takes 90 s with isolation on every project and 62 s with this rule.
A jsdom project keeps isolation and loses no time by it, because vmThreads above already gives one worker to many files. It also needs isolation: isolate: false shares the globals between the files of one worker, and three @xmachines/play-react tests fail under it.
A worker that runs without isolation keeps ONE module registry for all the files that it runs, so vi.mock applies only while the worker has not yet loaded the real module. The order that the pool picks decides the winner, and the suite then fails on some runs and passes on others. @xmachines/play-router showed it: one file mocked machine-to-graph.js while twenty-five sibling files called the real module, and the package failed three tests on one run of the whole suite and twenty-five on the next. The file now builds a real machine, the package needs no isolation, and it runs in 1.0 s rather than 5.4 s.
A project whose test files call vi.mock, vi.doMock, vi.stubGlobal, or vi.stubEnv declares isolate: true in its own vitest.config.ts, with the reason. tests/isolate-policy.test.ts holds this rule true: it asks vitest list which project collects which file, reads isolate from the config module, and names the file and the config when the two disagree. The scan follows the relative imports of each test file and reads the setup files of the project, so a fixture that mocks a module cannot hide, and it reads each @vitest-environment docblock, which reaches no config. Two projects declare it today: the play-sveltekit-router demo, which mocks $app/navigation, and the play-actor shared example, which stubs window.
Prefer the other answer where you can reach it. A test that builds the real collaborator needs no isolation, and it costs the whole project nothing.
A project that gives SOME of its files another environment isolates for the same reason. Vitest builds and tears down that environment inside a worker that keeps its module registry, so a module evaluated against the jsdom of one file stays cached for the next one and holds a window that no longer exists. @xmachines/play-react-router runs three React files that way.
A spy needs no isolation, and neither do fake timers. Each patches something that the test itself owns and restores, and neither depends on a fresh module registry.
To check a project that you move off isolation, run the suite with a shuffled file order — the same check that vitest doctor applies:
pnpm exec vitest run --sequence.shuffle.filesTest helpers and shared setup
@xmachines/shared/vitest-setup— Injects@testing-library/jest-dommatchers. Applied automatically bydefineXmVitestConfig.@xmachines/shared/vitest-node-setup— Enforces Node ≥ 24 at runtime. Auto-injected for non-browser configs.packages/play-react/test/test-utils.ts— React-specific test utilities for theplay-reactpackage.packages/play-router/examples/shared/andpackages/play-actor/examples/shared/— Shared test fixtures for router and actor integration tests.
Portable contract suites
Two suites live in @xmachines/play-router-shared and every router adapter runs them,
so a behaviour of RouterBridgeBase is asserted one time and covered eight times:
| Suite | Import | What it holds |
|---|---|---|
runBridgeContractTests | @xmachines/play-router-shared/test/router-bridge-contract.js | The bidirectional sync contract, with optional sections |
runBasePathContractTests | @xmachines/play-router-shared/test/base-path-contract.js | The basePath mount contract, and several machines |
An adapter supplies a harness rather than tests. For the basePath contract the
harness builds ONE router whose write reaches every bridge on it, and the suite mounts
one machine or two on that router. A browser behaves that way, and two bridges that
never see each other’s writes never contend for the address bar — which is the case the
suite exists to cover.
Add a new adapter to both suites. A copy of the assertions in the adapter is how the coverage drifted apart before: five adapters asserted the four behaviours of a mount, two asserted two of them, and the vanilla DOM adapter asserted only that the option reached the bridge.
Writing New Tests
Adding a new package test config
-
Place test files under
packages/<pkg>/test/. -
Name them following the convention above.
-
Import
defineXmVitestConfigin the package’svitest.config.ts:import { defineXmVitestConfig } from "@xmachines/shared/vitest";export default defineXmVitestConfig(import.meta.url, {test: {environment: "node",include: ["test/**/*.test.ts"],exclude: ["node_modules/**"],},}); -
Keep the
includeinsidetest/, and excludeexamples/. The demo config of the package owns every test underexamples/, and the root run joins both configs. -
Add the package’s
tsconfig.test.jsontotsconfig.test.jsonreferences in the root.
The root vitest.config.ts needs no edit: it collects packages/*/vitest.config.ts and packages/*/examples/*/vitest.config.ts by glob.
Basic test structure
import { describe, it, test, expect, vi, beforeEach, afterEach } from "vitest";
describe("ClassName or functionName()", () => { describe("feature group or scenario", () => { test("specific behavior being verified", () => { // arrange const actor = createMockActor("/");
// act actor.send({ type: "play.route", to: "#about" });
// assert expect(actor.currentRoute.get()).toBe("/about"); }); });});Import style: Always use .js extensions in imports (ESM requirement):
import { PlayActor } from "../src/abstract-actor.js";Lifecycle hooks
beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {});});
afterEach(() => { vi.restoreAllMocks(); // Always restore spies cleanup(); // For @testing-library/react or equivalent});What to mock / what not to mock
Mock these:
-
Framework router objects (TanStack Router, Vue Router, React Router, SolidJS Router) — mock with typed
vi.fn()interfaces because they are external framework dependencies and carry significant setup complexity:const mocks = vi.hoisted(() => ({ goto: vi.fn() }));vi.mock("$app/navigation", () => ({ goto: mocks.goto }));A mock of a module costs the WHOLE project its isolation — see Isolation. Mock the framework module, and not a module of the package under test: a test that builds the real collaborator is both cheaper and stronger.
-
console.warn/console.errorwhen testing code that legitimately emits warnings — mock to suppress noise and assert call counts:const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});// ... test ...expect(warnSpy).toHaveBeenCalled();vi.restoreAllMocks(); // in afterEach -
External module collaborators when testing a unit in isolation.
Never mock these:
- Atoms (
createAtom,watchAtom) — always use real implementations from@xmachines/play-atom. Atoms have precise reactive semantics (synchronous propagation, memoized derivation) that mocks cannot replicate accurately. - Internal
@xmachines/*packages — these are resolved directly to source via thexmAliasesVite plugin in the Vitest config. Mocking them would hide integration bugs and defeat the purpose of cross-package testing. - XState — always use real
setup()/createMachine()/createActor(). XState’s actor lifecycle (start, stop, snapshot, subscription) is a behavioral contract that mocks cannot reliably emulate.
Test actor patterns
Preferred: extend PlayActor for full type safety:
import { PlayActor } from "@xmachines/play-actor";import type { Routable } from "@xmachines/play-router";
class MockActor implements PlayActor, Routable { readonly state = createAtom({} as unknown); private _routeState: Atom<string | null>; readonly currentRoute: ReadonlyAtom<string | null>; readonly initialRoute: string | null;
constructor(startRoute: string | null = "/") { this._routeState = createAtom<string | null>(startRoute); this.currentRoute = createAtom(() => this._routeState.get()); this.initialRoute = startRoute; }
override send(_event: { readonly type: string } & Record<string, unknown>): void { // no-op or capture events for assertion }}Factory functions for lightweight inline mocks — use stubOf, never a double-cast:
import { stubOf } from "@xmachines/shared/test-support";
function createMockActor(initialView: PlaySpec | null = null) { return stubOf<ViewActor>({ currentView: createAtom<PlaySpec | null>(initialView), send: vi.fn(), start: vi.fn(), stop: vi.fn(), getSnapshot: vi.fn(), subscribe: vi.fn(), state: createAtom<unknown>({}), currentRoute: createAtom(() => null), });}stubOf keeps every declared member type-checked against the target type (a
send with the wrong signature is a compile error) while allowing the rest to
be absent — the one unsound assertion lives inside the helper, not at call
sites. X as unknown as T is banned in this repo; if stubOf cannot express
the case, unsafeCast<T>(value) from the same module is the deliberate,
greppable escape hatch.
Async and atom patterns
Flushing the microtask queue (required for watchAtom delivery):
function waitForMicrotask(): Promise<void> { return new Promise<void>((resolve) => queueMicrotask(resolve));}await waitForMicrotask();
// Or use Vitest's helper:await vi.waitFor(() => expect(result).toBe(expected), { timeout: 100 });Atom watch pattern:
import { createAtom, watchAtom } from "@xmachines/play-atom";
let notified: string | null = null;const stop = watchAtom(actor.currentRoute, (route) => { notified = route;});
actor.state.set({ path: "/dashboard" });// `watchAtom` coalesces into a microtask, so flush before the assertion.await new Promise((resolve) => setTimeout(resolve, 0));
expect(notified).toBe("/dashboard");stop(); // Always clean upatom.subscribe delivers SYNCHRONOUSLY, inside the set, and it needs no flush. Use it when a test must observe the notification at the moment of the write. Use watchAtom everywhere else, because it is what the renderers and the bridges use.
Compile-time type tests
For type-only assertions, use @ts-expect-error in .spec.ts files:
const bad: ViewMeta = typedSpec({ root: "root", elements: {}, // @ts-expect-error "stat" is not a ViewMeta field — typedSpec flags it on an // inline literal (excess-property checking does not reach aliased or spread // objects; use `satisfies ViewMeta` at the literal for those) stat: {},});// At runtime the object exists; only the compile-time error is being testedexpect(bad.root).toBe("root");For purely structural type assertions with no runtime test needed, use .typecheck.ts files in test/:
type IsAny<T> = 0 extends 1 & T ? true : false;type AssertFalse<T extends false> = T;
const actorNotAny: AssertFalse<IsAny<typeof actor>> = false;void actorNotAny;These files are validated by pnpm run test:build (tsc --build tsconfig.test.json --force) — never executed by Vitest.
Error testing
import { unsafeCast } from "@xmachines/shared/test-support";
// Synchronous throws — feed a runtime guard an invalid value through// unsafeCast, the named escape hatch (never `null as unknown as PlayEvent`).expect(() => actor.send(unsafeCast<PlayEvent>(null))).toThrow(InvalidEventError);
// Error with message patternexpect(() => extractMachineRoutes(dupMachine)).toThrow(/Duplicate route paths detected/);
// Inspect error propertiestry { extractMachineRoutes(dupMachine); expect.fail("Should have thrown");} catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); expect(message.toLowerCase()).toContain("duplicate");}An expected report in the log
A test that makes a component fail gets a report of the framework for free. React writes the error and a component stack to console.error when the root declares no onCaughtError, and @xmachines/play-solid writes a line of its own when the caller gives no onError. Both reports are correct, and both reach the log of the pipeline, where 29 of them once filled more than half of the test job.
Claim the report in the test that expects it, and assert it:
- React. Take
renderfromtest/test-utils.jsof@xmachines/play-react, and not from@testing-library/react. It gives anonCaughtErrorto the root, which stops the print, and the result carriescaughtErrorsfor an assertion. Keep therenderof the library for a test that holds the DEFAULT report: the print of React IS the report of play-react for a caller that gives noonError. - Any framework. Call
vi.spyOn(console, "error").mockImplementation(() => {})before the render, andexpect(spy).toHaveBeenCalled()after it. AnafterEachwithvi.restoreAllMocks()returns the console to the next test.
A report that a spy swallows and no assertion reads is worse than the noise, because it hides the day that the renderer stops to report.
Contract Tests
The @xmachines/play-router-shared package exports a shared behavioral contract suite that all router bridge adapters must satisfy. It lives here (rather than in @xmachines/play-router) because the suite drives a real actor via @xmachines/play-xstate and uses the shared authMachine fixture from @xmachines/play-actor-shared, so it sits one layer above the router package and keeps @xmachines/play-router free of any dependency on the actor runtime:
export function runBridgeContractTests(opts: ContractSuiteOptions): void;
// Each adapter's test file calls it:import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
runBridgeContractTests({ name: "TanStackReactRouterBridge", createHarness(initialPath) { /* ... */ }, createRestoredHarness(routedPath) { /* ... */ },});The contract covers: deep-link sync on connect, router→actor navigation sync, actor→router sync, guard redirect flows, duplicate event deduplication, and restore-from-snapshot behavior.
Used by: @xmachines/play-router, play-tanstack-react-router, play-vue-router, play-solid-router, play-react-router, and other adapter packages.
Compile-time bridge contract verification:
import { assertImplementsRouterBridge } from "@xmachines/play-router-shared/test/router-bridge-contract.js";import { MyRouterBridge } from "../src/my-router-bridge.js";
assertImplementsRouterBridge<MyRouterBridge>(); // Zero runtime costCoverage Requirements
Coverage is collected using the v8 provider. The root vitest.config.ts defines monorepo-wide regression thresholds:
| Type | Monorepo threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Branches | 75% |
| Statements | 80% |
Individual packages enforce their own (typically stricter) thresholds inside their vitest.config.ts:
| Package tier | Lines | Functions | Branches | Statements |
|---|---|---|---|---|
Core packages (@xmachines/play, @xmachines/play-actor, @xmachines/play-view) | 90% | 90% | 85% | 90% |
Complex logic (@xmachines/play-xstate, @xmachines/play-router) | 85% | 85% | 80% | 85% |
Integration packages (e.g. @xmachines/play-react, @xmachines/play-dom) | 80% | 80% | 80% | 80% |
@xmachines/play-router carries the lower numbers for a reason that its vitest.config.ts states: the bridge and the provider lifecycle are the mass of that package, and neither is measured by its own project. @xmachines/play-router-shared drives them through 3032 lines of contract suite, and each of the nine router adapters drives them again from its own project. The root pnpm run test:coverage is what measures the package whole.
Coverage includes: src/**/*.ts, src/**/*.tsx, src/**/*.vue, src/**/*.svelte
Coverage is excluded for: test/**/*, **/*.test.ts, **/*.test.tsx, **/*.d.ts, dist/**/*.
CI Integration
Tests run in GitLab CI using the to-be-continuous/node pipeline component (version 5.1.2).
Workflow: The pipeline runs on:
- Tag pushes (
$CI_COMMIT_TAG) - Pushes to the default branch (
$CI_DEFAULT_BRANCH) - Merge requests (
$CI_MERGE_REQUEST_IID)
CI test command (from .gitlab-ci.yml):
pnpm run test:coverageThe job passes no reporter flag. vitest.config.ts owns the reporters, the JUnit file and the coverage directory, and it reads COVERAGE_REPORTS_DIR — which the test job sets to reports/. A new package therefore needs no CI change.
Tests on the build output: test:dist runs pnpm run test:dist, and test:browser:dist runs pnpm run test:browser:dist on the Playwright image. Both take dist/ from node-build through needs:, so they test the build that the pipeline produced. They follow the rules of test, and they block: a failure is a defect of the tarball that a consumer installs, and it holds the release, because semantic-release runs in a later stage. They write reports/test-results-dist.xml and reports/browser-results-dist.xml, and each test case carries a dist: class name, so the widget of the merge request keeps them apart from the source runs. Neither job collects coverage.
Artifacts: The test job publishes:
- JUnit XML report at
reports/test-results.xml(surfaced in GitLab’s test results panel) - Cobertura coverage XML at
reports/cobertura-coverage.xml(used for GitLab’s coverage percentage badge — extracted from theAll filesline)
Coverage and test results are always uploaded (when: always) so failures are visible even when the job itself fails.
The pnpm run format:check and pnpm run lint commands enforce code style. They run as the separate format and lint jobs: the component’s own lint job is turned off with lint-enabled: "false", because it runs ESLint.
Test Types Reference
| Test type | Extension | Environment | Scope |
|---|---|---|---|
| Unit | .test.ts | node or jsdom | Single module in isolation |
| Protocol/type safety | .spec.ts | node | Compile-time type correctness + RFC invariants |
| Integration | .test.ts | node | Multiple modules working together |
| Performance/regression | .spec.ts | node | Timing budgets for critical paths |
| Browser unit | .browser.test.ts(x) | Chromium (Playwright) | Browser-specific APIs, real microtask timing |
| E2E demo | .browser.test.tsx | Chromium (Playwright) | Full application flows in demo packages |
| Type-only | .typecheck.ts (in src/) | tsc only | Structural type assertions; no Vitest runner |