Internals
Testing Internals
Bascik has three separate test suites: unit tests (Vitest) that verify individual library modules, integration tests (Vitest) that exercise real process, server, and worker boundaries, and end-to-end tests (Playwright) that build and browser-test the full transpilation pipeline against a fixture site.
Unit Test Coverage
Line, function, and branch coverage from the Vitest unit test suite. CLI entry points (index.ts, transpile.ts, page-worker.ts) are excluded. They run only in E2E tests below.
| Metric | Coverage | Covered / Total |
|---|---|---|
| Lines | 78.3% | 10765 / 13743 |
| Statements | 76.9% | 11759 / 15293 |
| Functions | 79.1% | 1584 / 2003 |
| Branches | 72.5% | 6877 / 9488 |
Generated by: yarn docs:update-coverage
E2E Coverage (Playwright + V8)
Coverage captured while the CLI builds the 55-page E2E fixture site. Exercises the full transpilation pipeline end-to-end including the CLI entry point, startup, and worker threads that unit tests cannot reach.
| Metric | Coverage | Covered / Total |
|---|---|---|
| Lines | 69.4% | 19699 / 28366 |
| Statements | 69.4% | 19699 / 28366 |
| Functions | 60.5% | 568 / 938 |
| Branches | 68.0% | 2695 / 3960 |
Generated by: yarn docs:update-e2e-coverage
Scaffolding CLI Coverage (create-bascik)
Coverage from the create-bascik Vitest test suite, verifying the project initializer and template generator scaffolding logic.
| Metric | Coverage | Covered / Total |
|---|---|---|
| Lines | 100.0% | 59 / 59 |
| Statements | 100.0% | 61 / 61 |
| Functions | 100.0% | 15 / 15 |
| Branches | 94.4% | 17 / 18 |
Generated by: yarn create:update-coverage
VS Code Extension Coverage (bascik-vscode)
Coverage from the bascik-vscode extension unit test suite, verifying compatibility rules and diagnostic engine logic.
| Metric | Coverage | Covered / Total |
|---|---|---|
| Lines | 100.0% | 2 / 2 |
| Statements | 100.0% | 3 / 3 |
| Functions | 100.0% | 1 / 1 |
| Branches | 100.0% | 0 / 0 |
Generated by: yarn ext:update-coverage
Cloudflare Adapter Coverage (@bascik/adapter-cloudflare)
Coverage from the @bascik/adapter-cloudflare Vitest unit test suite, verifying Wrangler config resolution, compatibility checks, and edge bundle generation.
| Metric | Coverage | Covered / Total |
|---|---|---|
| Lines | 52.5% | 157 / 299 |
| Statements | 49.4% | 166 / 336 |
| Functions | 36.8% | 14 / 38 |
| Branches | 38.7% | 89 / 230 |
Generated by: yarn adapter:cf:update-coverage
Testing Levels and Architecture
The codebase distinguishes test runners from testing levels:
- Unit tests (Vitest): Run with
yarn unit:allor per-workspace commands. Verify isolated functions, AST manipulation, and CSS scoping transforms using deterministic inputs and minimal mocks where isolation is required. - Integration tests (Vitest): Run separately with
yarn integration:allor per-packageintegrationcommands. Rather than isolating units, these exercise real subsystems collaborating across real boundaries. Key examples include real CLI child-process builds (worker-serial-parity.integration.test.ts), real worker thread lifecycles (worker-pool.real.integration.test.ts), real dev server module invalidation (dev-module-reload.integration.test.ts), real project scaffolding to compiler execution (scaffold.integration.test.ts), and serverless runtime parity (serverless-parity.integration.test.ts). - End-to-end tests (Playwright): Run via
yarn pkg:e2e,yarn pkg:e2e:dev, oryarn pkg:e2e:prod. These boot actual servers (static file server, live dev server with SSE, HTTP/1.1 cleartext, or HTTP/2 TLS production server) and navigate headless browsers to verify real DOM rendering, script execution, and user interaction.
The Real versus Mocked Boundary Rule
Integration tests in Bascik follow an explicit boundary contract: they do not mock away the primary boundary being verified. When testing worker thread lifecycle, real Node.js worker_threads run. When testing targeted-build persistence, real child processes invoke bascik --build against isolated disk fixtures. When testing scaffold output, the real scaffold() creates directory structures and files, and the real compiler parses and builds them into emitted artifacts. Mocks are reserved for external environment control (such as clock injection or network limits) rather than internal subsystem borders.
Running Unit Tests
Commands can be run per-package or across the workspace from the repository root:
# Workspace-wide unit tests
yarn unit:all
# Package-specific unit tests (single run)
yarn pkg:unit # @bascik/bascik
yarn create:unit # create-bascik
yarn ext:unit # bascik-vscode
yarn adapter:cf:unit # @bascik/adapter-cloudflare
# Interactive watch mode (pkg)
yarn pkg:unit:watch
# Single run with coverage
yarn pkg:coverage
yarn create:coverage
yarn ext:coverage
yarn adapter:cf:coverage
yarn coverage:all # update coverage across all packages
# Benchmarks
yarn pkg:bench Running Integration Tests
Integration tests spawn real processes, servers, and worker threads and are long-running, so they run in their own Vitest project and their own CI job, separate from the fast unit suite.
# Workspace-wide integration tests
yarn integration:all
# Package-specific integration tests (single run)
yarn pkg:integration # @bascik/bascik
# Interactive watch mode (pkg)
yarn pkg:integration:watch
# Run both unit and integration suites for the package
yarn pkg:test:all Running E2E Tests
End-to-end tests are run via:
# Static production server suite
yarn pkg:e2e
# Dev server suite (runs full E2E test suite + live-reload tests against the dev server)
yarn pkg:e2e:dev
# Production server suite (runs both HTTP/1.1 cleartext and HTTP/2 TLS server script tests against bascik --server)
yarn pkg:e2e:prod
# Or run HTTP/1.1 and HTTP/2 prod server suites individually:
yarn pkg:e2e:prod:http1
yarn pkg:e2e:prod:http2
# Cloudflare adapter suite in @bascik/adapter-cloudflare workspace (emitted Pages bundle in local workerd)
yarn adapter:cf:e2e This builds the fixture site (using the current dist/) and then runs Playwright against it. The first run requires the package to be built first:
yarn pkg:build && yarn pkg:e2e To run a specific test file or use the Playwright UI, run Playwright from pkg/:
# Run only CSS scoping tests against static server
npx playwright test --config e2e/playwright.config.ts e2e/tests/css-scoping.test.ts
# Run dev server live-reload tests against the dev server
npx playwright test --config e2e/playwright.dev.config.ts e2e/tests/dev-server-reload.test.ts
# Run HTTP/1.1 prod server tests against bascik --server
npx playwright test --config e2e/playwright.server.config.ts e2e/tests/prod-server.test.ts
# Run HTTP/2 prod server tests against bascik --server
npx playwright test --config e2e/playwright.server-http2.config.ts e2e/tests/prod-server.test.ts
# Open the Playwright UI for interactive debugging
npx playwright test --config e2e/playwright.config.ts --ui How the E2E Suite Works
The e2e fixture is a small but complete Bascik project at pkg/e2e/:
pkg/e2e/
bascik.config.ts ← fixture config (minify.identifiers: false)
playwright.config.ts ← static build test runner
playwright.server.config.ts ← HTTP/1.1 cleartext prod server runner
playwright.server-http2.config.ts← HTTP/2 TLS prod server runner
playwright.dev.config.ts ← dev server runner for live-reload and open-page priority
server.ts ← minimal static HTTP server for dist/
src/
pages/ ← one HTML page per feature under test
components/ ← components used by those pages
tests/ ← Playwright test files The E2E suite supports four Bascik server modes plus three exec lanes and one adapter lane:
- Static production suite (
playwright.config.ts): builds the fixture site withbascik --buildand serves static files viaserver.tson port 4200. - HTTP/1.1 production server suite (
playwright.server.config.ts): boots cleartextbascik --serverover HTTP/1.1 on port 9443 to testdata-bascik-serverrequest-time script execution and cleartext server behavior. - HTTP/2 production server suite (
playwright.server-http2.config.ts): boots TLS-enabledbascik --serverover HTTP/2 on port 9444 to testdata-bascik-serverrequest-time script execution and encrypted server behavior. - Dev server watch suite (
playwright.dev.config.ts): boots the live dev server on port 9443 (configured viaBASCIK_SERVER_PORT=9443) to run the full test suite and live-reload watcher tests directly against the live dev server with SSE tracking and open-page priority re-transpilation. - Exec lifecycle lanes (
playwright.dev-exec.config.ts,playwright.dev-exec-gated.config.ts,playwright.exec-build.config.ts): run against a dedicatede2e/exec-fixture/project to prove exec producer/consumer lifecycle ownership. The dev lanes run viayarn pkg:e2e:dev:exec; the gated variant holds aphase: 'parallel'producer behind an HTTP gate to prove the dev server serves before the parallel entry finishes. - Exec failure policy lane (
playwright.exec-policy.config.ts): spawns the real CLI in temporary projects and checkspipeline.onExecError: exit codes, output, the build-error frame an open browser receives, and that a dev session survives a failing script and recovers on the next edit. It covers the dev default ('warn'), the build default ('error'), the option set explicitly, and thedevandbuildconfig exports. It runs viayarn pkg:e2e:exec:policyand is excluded from the four fixture-serving configs. - Cloudflare adapter suite (
adapters/cloudflare/e2e/playwright.config.ts): builds the small fixture with--target cloudflare-pagesand serves the emitted_worker.jsand public tree inside local workerd (Miniflare) with the asset layer in front, on port 9876. It covers static bypass, worker-side pages and APIs, and a stream paint-order test with JavaScript disabled. Request-level Node-versus-Worker parity is a Vitest integration test (serverless-parity.integration.test.ts), which runs one build under bothbascik --serverand workerd and compares them. This lane lives in the@bascik/adapter-cloudflareworkspace (yarn adapter:cf:e2e), not inpkg/e2e/.
Each configuration owns its mode-specific testIgnore list on the default project. Playwright project arrays replace matching top-level arrays rather than extending them, so splitting exclusions across both levels can silently run server-only tests in the wrong mode. For example, server-scripts.test.ts and server-scripts-stream.test.ts run under dev, HTTP/1.1, and HTTP/2 configs, and are ignored under the static config because the static file server cannot execute server scripts. A unit regression test imports the four Bascik-server configs and pins their project exclusions before any browser starts; the Cloudflare config selects its single test file with testMatch and is excluded from the other four.
Tests navigate to pages on the active server and assert against the live browser DOM.
Fixture Design
minify.identifiers is kept at false in the fixture config so Playwright selectors can use readable scoped names like bascik__my-comp__btn. The site URL is supplied per run through the BASCIK_SITE_URL environment variable in each Playwright webServer command, and the server port is set via a server mode override that each lane can change with BASCIK_SERVER_PORT. The fixture config also exercises other features (multiple component roots, exec scripts, custom minifiers), so this is an excerpt:
// pkg/e2e/bascik.config.ts (excerpt)
import { defineConfig } from '@bascik/bascik/config';
export default defineConfig({
pipeline: { workers: true },
minify: { identifiers: false },
});
export const server = defineConfig({
http: { port: Number(process.env.BASCIK_SERVER_PORT) || 9443 },
}); Each fixture page renders two or more instances of the component under test so isolation can be verified, changes to instance A must not affect instance B.
Testing philosophy: scoping engine verification
Use locator strategy based on test intent:
- Behavior-oriented E2E tests: Prefer resilient user-facing locators like
page.getByRole(...),page.getByLabel(...), and explicitpage.getByTestId(...), so assertions survive identifier minification and style refactors. - Compiler-output verification tests: When the transformed selector or identifier is the behavior under test, deliberately assert transform-aware selectors (for example generated scoped class names or rewritten IDs).
This keeps ordinary feature tests robust while still verifying compiler transforms directly when required.
Repeated-instance scoping verification
The Vitest projects in pkg/ and docs/, and every Playwright config, set BASCIK_VERIFY_SCOPING_TEMPLATES=1. With it, each scoping result reused for a repeated component instance is also recomputed by the full pipeline, and any difference throws. scoping-parametricity.test.ts checks the underlying property directly: on random components (fast-check) and on every component in docs/ and pkg/e2e/, renaming one instance's generated names must give exactly the full pipeline's output for another instance ID, with and without identifier minification. Set SCOPING_PROPERTY_RUNS to raise the number of random cases for a deeper local run. scoping-template.test.ts pins the component fields and config the pipeline reads and writes with recording proxies, so a new input fails a test instead of being left out of the reuse key.
E2E Test Files
Each test file is paired with a fixture page. See the full list on GitHub.
Writing a New E2E Test
- Add a fixture component in
pkg/e2e/src/components/my-feature/with an HTML file (and CSS/JS as needed). - Add a fixture page in
pkg/e2e/src/pages/my-feature-test.htmlthat renders two or more instances of the component. - Add a test file at
pkg/e2e/tests/my-feature.test.ts.
A typical test file:
import { test, expect, type Locator } from '@playwright/test';
function getInstance(page, n: number): Locator {
return page.locator('.bascik__my-feature__wrapper').nth(n);
}
test.describe('my-feature-test page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/my-feature-test');
});
test('instances are isolated', async ({ page }) => {
const a = getInstance(page, 0);
const b = getInstance(page, 1);
// assert that state in A does not affect B
});
}); Rebuild before testing. Playwright tests run against e2e/dist/, which is built from the current pkg/dist/. If you change pkg/src/, run yarn pkg:build before yarn pkg:e2e so the fixture picks up the latest transpiler.
Test Configuration
Vitest is configured in pkg/vite.config.js with two projects: unit (fast, excludes *.integration.test.ts) and integration (only *.integration.test.ts):
export default defineConfig({
test: {
benchmark: {
include: ["bench/**/*.bench.ts"],
},
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "lcov"],
reportsDirectory: "./coverage",
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts"],
},
projects: [
{
test: {
name: "unit",
include: ["src/**/*.test.ts"],
exclude: ["src/**/*.integration.test.ts"],
},
},
{
test: {
name: "integration",
include: ["src/**/*.integration.test.ts"],
// Projects inherit the root `benchmark.include`; run benchmarks once.
benchmark: { exclude: ["bench/**"] },
},
},
],
},
}); Coverage is collected via V8 and written to pkg/coverage/. The CI unit script uses text-summary only. The full HTML report at coverage/index.html is useful locally.
Unit Test Files
Each library module has a paired test file. See the full list on GitHub.
Writing Tests
Tests use the standard Vitest describe / it / expect API. Because library modules depend on BascikConfig (a module-level singleton), tests that need a specific configuration use vi.mock to stub it:
import { describe, expect, it, vi } from "vitest";
vi.mock("../config.ts", () => ({
BascikConfig: {
scoping: {
scriptBlocks: true,
attributes: { class: true, id: true, name: true },
},
isBuild: false,
minify: { css: false, identifiers: false },
},
}));
// Import the module under test AFTER mocking its dependencies
import { prefixElementAttribute } from "./javascript.js";
describe("prefixElementAttribute", () => {
it("scopes class attributes in HTML", () => {
const component = {
name: "my-comp",
fileContent: '<div class="btn">Click</div>',
};
const result = prefixElementAttribute(component, "class", "abc123");
expect(result.fileContent).toContain("bascik__my-comp__btn");
});
}); Important. Always import the module under test after calling vi.mock. Vitest hoists mock calls to the top of the file, but the import order still matters for ensuring the mock is in place when the module initializes its dependencies.
Boundary Test Patterns
Real-Filesystem Watch Testing
In addition to mocked chokidar tests, watch mode includes real-filesystem tests in isolated temporary directories (watch-fs.test.ts). These verify real-world filesystem event sequences, atomic editor saves (temp file write followed by rename), stability thresholds (awaitWriteFinish), and debounce behavior without false-confidence gaps.
Time-Boundary Testing Model
Bascik uses five test mechanics for time-sensitive behavior. This keeps framework timing deterministic in unit tests while preserving real runtime boundaries in integration tests:
- Framework internals with fake timers: Modules that own semantic time accept
FrameworkClockand are tested with Vitest fake timers (vi.useFakeTimers()). - Static architectural enforcement:
time-boundary.test.tsprevents direct ambient timer usage in designated semantic-time modules. - Cross-process E2E deadlines with real time: Playwright runs Bascik in a separate process, so E2E timeout assertions use short real deadlines to verify
AbortSignalpropagation and HTTP timeout responses. - Browser context timing with
page.clock: Browser-only scheduling behavior is tested with Playwright clock controls in the browser runtime. - External watchdogs on wall clock: Startup, sockets, and filesystem event flows use real time to match production boundaries.
For architecture ownership, cancellation rules, and timeout surfaces, see Time Boundaries.
Exec Lifecycle
Exec scripts run in pre, parallel, and post phases around compilation. The tests pin that ordering with explicit gates rather than fixed delays:
source-cycle.test.tsdrives the source cycle with injected clocks and promise gates to verify phase ordering, overlapping edits, retained changes, failure recovery, and nonblockingparallelwork.watch-source.integration.test.tsruns the source observer against the real cycle coordinator, covering glob roots, output exclusion, and dependency routing.- Compilation publication tests verify that page reloads are buffered per async scope and that dev disk writes are joined before
postruns. - The exec Playwright lanes hold
preandparallelchildren behind HTTP release gates. Real CLI build fixtures run in temporary project directories and use an event-driven artifact gate to prove thatpostsees compiled pages whileparallelis still running. Serial and worker builds both wait forparallelto finish before reporting success. - Fixture scripts write generated artifacts only to
dist/, and watchers observe source inputs, never generated files.
Runtime Resource and Retention Tests
Some integration tests measure the runtime itself rather than a single feature:
module-retention.integration.test.tsand its siblings check what stays in memory as request modules are edited and reloaded, using changing-source and stable-source controls and heap retainer paths. They pin the behavior described under Module Lifetime: clearing framework caches does not evict Node's ESM cache.profile-resource-boundaries.integration.test.tsverifies that timers, descriptors, streams, child processes, and worker transfers are released at each boundary, using explicit producer and consumer gates and event-loop checkpoints.profile-workload.integration.test.tsandprofile-fixture-tls.integration.test.tsvalidate the profiling harness itself.
These tests run isolated native processes. A failing run keeps its private report directory and prints the path, and a passing run cleans up after itself.
Benchmarks and Profiling
yarn pkg:bench runs Vitest throughput benchmarks from pkg/bench/ on fixed, repeatable inputs. The same directory contains a bounded profiling and comparison harness (profile:compare, profile:compiler, and profile:workload) for paired before-and-after compiler builds with byte-identical verification, live dev-edit timelines, and runtime profiling.
See Benchmarking & Profiling for the complete guide, commands, and report directory rules, or the benchmarks and profiling README in the repository.
TypeScript Checking
Run type checking across all packages or for individual packages from the repository root:
# Type check all packages
yarn typecheck:all
# Package-specific type checks
yarn pkg:typecheck
yarn create:typecheck
yarn ext:typecheck Monorepo Aggregator Commands
The root package.json provides aggregated tasks across all projects:
yarn typecheck:all: runs typechecks across all packages in the workspaceyarn check:all: runs spelling (check:spelling) and web standards (check:standards)yarn unit:all: runs unit test suites across all packagesyarn integration:all: runs the long-running integration test suiteyarn e2e:all: runs Playwright E2E suites across the workspaceyarn coverage:all: generates and updates coverage reports across all packagesyarn test:all: runs typechecks, spelling/standards checks, unit tests, integration tests, and E2E suites in sequence (coverage excluded)
Contributing a Fix
- Fork the repository and create a branch.
- Make your changes in
pkg/src/. - Add or update tests in the paired
*.test.tsfile. - Run
yarn pkg:unitand ensure all tests pass. - Run
yarn pkg:integrationif your change touches a real process, server, or worker boundary. - Run
yarn pkg:typecheckto confirm there are no TypeScript errors. - Open a pull request against
main.