Testing Internals

Bascik organizes verification across three distinct levels: unit tests that verify isolated functions and modules, integration tests that exercise real process boundaries, compilers, and cross-subsystem compositions, and end-to-end tests (Playwright) that build and browser-test the full transpilation pipeline against a live server.

Test Coverage

Each workspace commits a test-coverage.json with its coverage totals, and the table below reads all eight files when the docs are built. Run yarn coverage:all to regenerate every file, or the workspace's own script named in the table.

  • Unit rows come from each workspace's Vitest unit tests. @bascik/bascik counts its unit project only, never its integration tests or linear-time checks. Its CLI entry points (index.ts, transpile.ts, page-worker.ts) are excluded, because only the E2E run reaches them.
  • E2E build steps is measured separately, with V8 coverage captured while the CLI builds the E2E fixture site and runs the dev and production servers. It covers the entry points, startup, and worker threads that unit tests cannot reach.
  • bascik-vscode is low because most of the extension calls the VS Code API and runs only in the yarn ext:e2e suite, which is not measured. The diagnostics, completions, hovers, and definitions it shows are tested in @bascik/language-server.
  • bascik-docs includes the docs' integration tests and covers the build-time helpers in docs/src/lib/ and the scripts in docs/scripts/.
  • bascik-skills-tests measures the public agent skills' scripts in skills/.
Percentage covered, then covered and total counts, from each workspace's committed coverage JSON
WorkspaceSuiteLinesStatementsFunctionsBranchesGenerated by
@bascik/bascik
pkg/
Unit93.5%
18250 / 19522
92.4%
19851 / 21487
92.0%
2650 / 2881
86.7%
12611 / 14552
yarn pkg:coverage
@bascik/bascik
pkg/
E2E build steps69.4%
39187 / 56476
69.4%
39187 / 56476
60.6%
1036 / 1708
69.4%
4758 / 6853
yarn pkg:coverage
create-bascik
create/
Unit98.6%
430 / 436
98.3%
464 / 472
98.9%
87 / 88
93.9%
338 / 360
yarn create:coverage
@bascik/language-server
lsp/
Unit92.9%
7892 / 8492
90.5%
8873 / 9805
92.5%
931 / 1007
85.9%
7412 / 8624
yarn lsp:coverage
bascik-vscode
extensions/vscode-bascik/
Unit46.2%
43 / 93
47.7%
51 / 107
46.7%
14 / 30
60.0%
27 / 45
yarn ext:coverage
@bascik/adapter-cloudflare
adapters/cloudflare/
Unit97.9%
801 / 818
96.7%
879 / 909
93.7%
118 / 126
89.0%
633 / 711
yarn adapter:cf:coverage
bascik-docs
docs/
Unit and integration63.2%
2310 / 3654
62.7%
2482 / 3956
68.3%
401 / 587
69.4%
1321 / 1903
yarn docs:coverage
bascik-skills-tests
skills-tests/
Unit57.3%
4474 / 7812
56.5%
5096 / 9023
57.9%
872 / 1507
56.4%
4004 / 7096
yarn skills:coverage

Testing Levels and Architecture

The codebase distinguishes test runners from testing levels:

  • Unit tests (Vitest): Run with yarn unit:all or per-workspace commands. Verify isolated functions, HTML, CSS, and JavaScript string transforms, and CSS scoping using deterministic inputs and minimal mocks where isolation is required.
  • Integration tests (Vitest): Run separately with yarn integration:all or per-package integration commands. 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).
  • Linear-time checks (Vitest): Run separately with yarn linear-time:all or per-package linear-time commands. They time CPU growth on hostile input (see Time-Boundary Testing Model), which takes seconds per check on Windows, so they live in *.linear-time.test.ts files outside the unit suite.
  • End-to-end tests (Playwright): Run via yarn pkg:e2e, yarn pkg:e2e:dev, or yarn 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.

Adapter development versus production acceptance

Watched adapter development (bascik --target cloudflare or bascik --target netlify) adds a source watcher, full production adapter rebuilds, a local HTTP proxy, and browser live reload around the real platform runtime. Tests of that workflow must cover edits and recovery after a failed build across the CLI, runtime, proxy, and browser boundaries. A rebuilding or failed build may return 503; recovery means the watcher remains alive and serves a successful subsequent edit, not guaranteed last-good serving. The proxy is HTTP only, and emitted production artifacts must stay free of live-reload injection.

Production acceptance is distinct: build once with --build --target ... and serve the generated output directly in Wrangler or Netlify Dev. In particular, the Netlify E2E configuration runs adapter:nf:demo:build followed by the adapter workspace's demo:serve, not the watched adapter:nf:demo:dev. This keeps header, static bypass, streaming, and native-function assertions about production responses. The watched repository demos use port 8787 for Cloudflare and 9877 for Netlify; a normal Netlify project's default is 8888.

Differential fuzzing is a separate opt-in developer check: yarn workspace @bascik/bascik profile:fuzz compares a built baseline and candidate on generated inputs and reports shrunk, replayable mismatches. The unit suite tests the runner itself without a baseline checkout. See Benchmarking & Profiling for commands and scope.

Running Unit Tests

Commands can be run per-package or across the workspace from the repository root:

sh
# Workspace-wide unit tests
yarn unit:all

# Package-specific unit tests (single run)
yarn pkg:unit         # @bascik/bascik
yarn create:unit      # create-bascik
yarn docs:unit        # bascik-docs
yarn ext:unit         # bascik-vscode
yarn adapter:cf:unit  # @bascik/adapter-cloudflare
yarn lsp:unit         # @bascik/language-server
yarn skills:unit      # bascik-skills-tests (the public agent skills)

# Interactive watch mode (pkg)
yarn pkg:unit:watch

# Single run with coverage
yarn pkg:coverage
yarn create:coverage
yarn docs:coverage
yarn ext:coverage
yarn adapter:cf:coverage
yarn lsp:coverage
yarn skills: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.

sh
# Workspace-wide integration tests
yarn integration:all

# Package-specific integration tests (single run)
yarn pkg:integration  # @bascik/bascik
yarn docs:integration # bascik-docs
yarn skills:integration # bascik-skills-tests (bascik-refactor parity checks in Chromium)

# Interactive watch mode (pkg)
yarn pkg:integration:watch

# Run the unit, integration, and linear-time suites for the package
yarn pkg:test:all

Running Linear-Time Checks

The checks that guard against super-linear work (see Time-Boundary Testing Model) live in *.linear-time.test.ts files next to the module's other tests, and run in their own Vitest project. Each check times several samples in CPU time, which Windows counts in ticks of about 15.6 ms, so a check takes seconds there and milliseconds on Linux and macOS. Keeping them out of the unit project keeps the unit suite fast on every operating system, and keeps them out of coverage runs, whose instrumentation skews a growth ratio. pkg, lsp, and skills-tests each have a linear-time project. CI runs them once, on Linux, in the integration stage.

sh
# Workspace-wide linear-time checks
yarn linear-time:all

# One workspace
yarn pkg:linear-time
yarn lsp:linear-time
yarn skills:linear-time

Running E2E Tests

End-to-end tests are run via:

sh
# 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

# All four lanes at once (static, dev, http1, http2), one summary line per lane
yarn pkg:e2e:all
yarn pkg:e2e:all dev http1                    # a subset
yarn pkg:e2e:all http2 --grep scoping         # extra args go to every lane
yarn pkg:e2e:all:serial                       # the old one-after-another chain

# Exec lanes (run on their own, not by pkg:e2e:all)
yarn pkg:e2e:dev:exec      # dev exec lifecycle, plain and gated
yarn pkg:e2e:exec:policy   # pipeline.onExecError in temporary projects
yarn pkg:e2e:exec:build    # build-time pre/parallel/post ordering in temporary projects

# Cloudflare adapter suite in @bascik/adapter-cloudflare workspace (emitted Pages bundle in local workerd)
yarn adapter:cf:e2e

# Netlify adapter suite (real offline Netlify Dev runtime, or dedicated hosted project)
yarn adapter:nf:e2e
yarn adapter:nf:demo:test

# Watched adapter development (build pkg and the relevant adapter first)
yarn adapter:cf:e2e:dev
yarn adapter:nf:e2e:dev
yarn adapter:e2e:dev       # both platforms; requires both adapter builds

The watched lanes use adapters/e2e/playwright.dev.config.ts and adapter-dev.test.ts to run actual Wrangler and Netlify Dev processes and verify source rebuilds and browser reloads through the HTTP proxy. CI runs each platform's watched lane after its build and production acceptance step. These tests are separate from the direct-runtime production acceptance contract below.

Both adapter lanes also run the same browser contract in adapters/e2e/demo-contract.test.ts against their real hosting runtimes. Their demo sites use the same canonical routes, page titles, menu labels, and product data: overview, server scripts, stream swap, stream append, mixed execution, server-rendered cards, streamed cards, API client, and error demo. The shared suite checks those surfaces together with query escaping, component isolation, API methods and route precedence, no-JavaScript streaming paint order, safe failures, and private-path handling. Platform-specific tests remain separate: Cloudflare context and Worker routing, and Netlify context, cookies, and native function coexistence. A capability common to both adapters belongs in the shared contract, not just one platform's suite.

Hosting layers can normalize a request before the adapter receives it. Netlify Dev collapses repeated URL slashes: the shared contract accepts only an HTTP 400 rejection or the exact canonical catch-all segments for that case. Encoded separators, invalid encodings, and null bytes still require HTTP 400 on both.

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:

sh
yarn pkg:build && yarn pkg:e2e

yarn pkg:e2e:all runs pkg/e2e/scripts/run-lanes.ts, which starts the four lanes in parallel, prefixes each output line with the lane name, and exits nonzero if any lane fails. Before starting anything it refuses to run, naming the lane and port, if a port a lane needs is already bound. The dev lane starts once the other lanes' builds are serving, because it edits the fixture sources they build from. An unknown lane name is rejected before any lane starts; to pass a bare word to Playwright, put it after --.

To run a specific test file or use the Playwright UI, run Playwright from pkg/:

sh
# 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/:

text
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 a lane's output dir
  scripts/run-lanes.ts            ← runs the four main lanes in parallel (yarn pkg:e2e:all)
  tests/helpers/out-dir.ts        ← the one place specs resolve a build output directory
  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 two adapter lanes:

  1. Static production suite (playwright.config.ts): builds the fixture site with bascik --build and serves static files via server.ts on port 4200 (base fixture on 9550).
  2. HTTP/1.1 production server suite (playwright.server.config.ts): boots cleartext bascik --server over HTTP/1.1 on port 9443 (base fixture on 9552) to test data-bascik-server request-time script execution and cleartext server behavior.
  3. HTTP/2 production server suite (playwright.server-http2.config.ts): boots TLS-enabled bascik --server over HTTP/2 on port 9444 (base fixture on 9553) to test data-bascik-server request-time script execution and encrypted server behavior.
  4. Dev server watch suite (playwright.dev.config.ts): boots the live dev server on port 9445 (configured via BASCIK_SERVER_PORT=9445; base fixture on 9551, symlink fixture on 9663) 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. Read-only specs run four at a time in the dev-readonly project; the specs that edit watched files (devSourceEditingSpecs in the config) then run one at a time in dev-mutating, which depends on it.
  5. Exec lifecycle lanes (playwright.dev-exec.config.ts, playwright.dev-exec-gated.config.ts, playwright.exec-build.config.ts): prove exec producer/consumer lifecycle ownership. The dev lanes run against a dedicated e2e/exec-fixture/ project via yarn pkg:e2e:dev:exec; the gated variant holds a phase: 'parallel' producer behind an HTTP gate to prove the dev server serves before the parallel entry finishes. The build lane (yarn pkg:e2e:exec:build) runs bascik --build in temporary projects, with and without worker threads, and checks that a phase: 'parallel' script joins after compilation and post, and that its failure fails the build.
  6. Exec failure policy lane (playwright.exec-policy.config.ts): spawns the real CLI in temporary projects and checks pipeline.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 the dev and build config exports. It runs via yarn pkg:e2e:exec:policy and is excluded from the four fixture-serving configs.
  7. Cloudflare adapter suite (adapters/cloudflare/e2e/playwright.config.ts): builds the small fixture with --target cloudflare-pages and serves the emitted _worker.js and 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 both bascik --server and workerd and compares them. This lane lives in the @bascik/adapter-cloudflare workspace (yarn adapter:cf:e2e), not in pkg/e2e/.
  8. Netlify adapter suite (adapters/netlify/e2e/playwright.config.ts): builds the independent fixture workspace with the real CLI and installer, and runs its generated function, native function, and static output through offline Netlify Dev on ports 9877/9878. yarn adapter:nf:e2e covers static bypass, SSR, no-JavaScript stream paint order, runtime components, APIs, cookie/context transport, CSP/security headers, and failure/timeout behavior. CI runs this lane without credentials. yarn adapter:nf:demo:test runs the same suite against the dedicated HTTPS project; its exact foreign-origin 403 check is hosted-only because Netlify Dev 27.14.2 retries function 403s as static paths and returns 404. Identity, OAuth, databases and payments are not covered. The adapter's Vitest parity test separately compares a real CLI build, called in-process, with bascik --server.

Each of the four main lanes also serves e2e/base-fixture/, a second project with its own config: it deploys under base: "/sub/" and opts in to scoping.attributes.name, which pkg/e2e itself leaves at the default (off). Its specs are BASE_FIXTURE_SPECS in each config (base-serving.test.ts, base-name-scoping.test.ts).

Every port across the four main lanes, the exec lanes, the docs e2e (8080) and the adapter e2e lanes (Cloudflare 9876, Netlify 9877/9878) is distinct, and each main lane config sets BASCIK_E2E_OUT (dist-static, dist-dev, dist-http1, dist-http2), which pkg/e2e/bascik.config.ts and base-fixture/bascik.config.ts use as directory.out. Each lane config also sets its own Playwright outputDir (pkg/test-results/<lane>), so no lane clears another's results or .last-run.json. Run the main lanes together only through yarn pkg:e2e:all: the dev lane edits pkg/e2e/src and base-fixture/src, which the other lanes build from, and the runner holds it back until their builds are serving. Starting yarn pkg:e2e:dev by hand next to another lane has no such guard. Specs read build output only through outDir() or defaultOutDir() in tests/helpers/out-dir.ts; the symlink fixture, exec fixture, and temporary projects keep plain dist. e2e/scripts/run-lanes.test.ts (in the unit project) fails on a port collision, a hardcoded dist/ path or server origin in a spec, a file-writing spec left in dev-readonly, a dev-mutating edit of a module the production lanes import at request time (an API route other specs call, or a server-script dependency), or a Playwright config in pkg/e2e/ that no package script and CI job runs.

Each configuration owns its mode-specific testIgnore list on its main project (default, or dev-readonly in the dev config). 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:

ts
// 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 explicit page.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/, docs/, and adapters/cloudflare/, and every Playwright config, set BASCIK_VERIFY_SCOPING_TEMPLATES=1 (the builds those tests spawn inherit it). 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. scoping-template-adversarial.test.ts does the same for the constructs the scoping engine reads by pattern (escaped and non-ASCII class selectors, dotted @layer names, [id] forms, multi-name containers, nested & chains, compound querySelector selectors, comparisons in scripts) and typo'd variants of each, and request-render-node-scoping-parity.test.ts and the Cloudflare adapter's serverless-parity.integration.test.ts check the request-time replay of the same templates against the build. 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.

Incremental mask verification

The component expansion loop keeps the page's mask (comments and raw-text element contents blanked) current across splices instead of re-masking the page, and resumes each component search at a cursor. component-expansion-mask-oracle.test.ts checks that against a whole re-read: a test-only probe (__expansionProbeForTests in component-scan-stats.ts) hands it the loop's state after every splice and every search, and on random pages from a grammar of nested components, slots, raw-text elements, comments, CDATA, multi-line tags and unclosed elements (fast-check) it compares the mask with maskRawTextContent, the last-close facts with the closes in that mask, and the search with one from the start of the page. A shrunk counterexample is pinned there as a fixed input. Set MASK_ORACLE_RUNS to raise the number of random pages for a deeper local run.

E2E Test Files

Each test file is paired with a fixture page. See the full list on GitHub.

Writing a New E2E Test

  1. Add a fixture component in pkg/e2e/src/components/my-feature/ with an HTML file (and CSS/JS as needed).
  2. Add a fixture page in pkg/e2e/src/pages/my-feature-test.html that renders two or more instances of the component.
  3. Add a test file at pkg/e2e/tests/my-feature.test.ts.

A typical test file:

ts
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 the lane's output directory (e2e/dist-static/, e2e/dist-dev/, and so on), 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 three projects: unit (fast, excludes *.integration.test.ts and *.linear-time.test.ts), integration (only *.integration.test.ts), and linear-time (only *.linear-time.test.ts):

js
export default defineConfig({
  test: {
    benchmark: {
      include: ["bench/**/*.bench.ts"],
      suppressExportGetterWarnings: true,
    },
    coverage: {
      provider: "v8",
      reporter: ["text", "json-summary", "lcov"],
      reportsDirectory: "./coverage",
      include: ["src/**/*.ts"],
      exclude: ["src/**/*.test.ts", "src/**/*.test-helper.ts"],
    },
    projects: [
      {
        test: {
          name: "unit",
          include: ["src/**/*.test.ts", "e2e/scripts/**/*.test.ts"],
          exclude: [
            "src/**/*.integration.test.ts",
            "src/**/*.linear-time.test.ts",
          ],
          env: { BASCIK_VERIFY_SCOPING_TEMPLATES: "1" },
          // Whether the run collects coverage, for calibrated cost ratios.
          globalSetup: ["./vitest.coverage-setup.ts"],
        },
      },
      {
        test: {
          name: "integration",
          include: ["src/**/*.integration.test.ts"],
          // Projects inherit the root `benchmark.include`; run benchmarks once.
          benchmark: { exclude: ["bench/**"] },
          env: { BASCIK_VERIFY_SCOPING_TEMPLATES: "1" },
          globalSetup: ["./vitest.coverage-setup.ts"],
        },
      },
      {
        test: {
          name: "linear-time",
          include: ["src/**/*.linear-time.test.ts"],
          benchmark: { exclude: ["bench/**"] },
          env: { BASCIK_VERIFY_SCOPING_TEMPLATES: "1" },
          globalSetup: ["./vitest.coverage-setup.ts"],
        },
      },
    ],
  },
});

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.

Agent Skill Tests

The public agent skills in skills/ ship to users, so their tests and fixtures live in their own workspace, skills-tests/ (bascik-skills-tests), not in skills/ or docs/. Unit tests import each skill script and run it against throwaway projects in a temp directory. The integration tests drive the bascik-refactor parity checks in Chromium against the fixture pages in skills-tests/tests/fixtures/refactor-parity/, and build a two-site monorepo with the real compiler and linter. Tests of how the docs site publishes the skills stay in docs/scripts/. See the files 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:

ts
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.ts";

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.

Bounding Property Tests

A fast-check property with no explicit bounds draws a new random seed on every run and runs under Vitest's 5 s default timeout, so its cost changes from run to run and a slow CI runner can time it out. Pass each fc.assert the options from propertyBounds(ciRuns, runsVariable?) in pkg/src/lib/property-runs.test-helper.ts, and its timeoutMs to it:

ts
import { propertyBounds } from "./property-runs.test-helper.ts";

const BOUNDS = propertyBounds(100, "MASK_ORACLE_RUNS");
it(
  "keeps the mask equal to a whole re-read",
  () => {
    fc.assert(fc.property(pageArb, checkPage), BOUNDS.options);
  },
  BOUNDS.timeoutMs,
);

The property then runs ciRuns cases from a fixed seed with a 30 s timeout. Size ciRuns so the test takes well under a second on a developer machine; the timeout is the margin for a slow runner, such as the Node 24.21 floor job. The optional variable raises the count for a deeper local run (MASK_ORACLE_RUNS=20000 yarn pkg:unit), and the timeout grows with it. PROPERTY_SEED starts every bounded property from another seed. A variable that is empty or not a positive whole number throws with its name and value, because fast-check runs no case at all for a numRuns of NaN or 0 and passes.

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:

  1. Framework internals with fake timers: Modules that own semantic time accept FrameworkClock and are tested with Vitest fake timers (vi.useFakeTimers()).
  2. Static architectural enforcement: time-boundary.test.ts prevents direct ambient timer usage in designated semantic-time modules.
  3. Cross-process E2E deadlines with real time: Playwright runs Bascik in a separate process, so E2E timeout assertions use short real deadlines to verify AbortSignal propagation and HTTP timeout responses.
  4. Browser context timing with page.clock: Browser-only scheduling behavior is tested with Playwright clock controls in the browser runtime.
  5. External watchdogs on wall clock: Startup, sockets, and filesystem event flows use real time to match production boundaries.

Tests that guard against super-linear work on hostile input (masking, tag and attribute scans, request-time rendering) do not use fixed wall-clock bounds, which flaked on loaded CI runners. linear-time.test-helper.ts (expectLinearTime, expectLinearTimeAsync) times a quarter-size and a full-size input in this thread's CPU time, interleaved over several samples, keeps the fastest of each, and requires the growth ratio to stay under MAX_GROWTH (12): linear work grows about 4 times when the input quadruples, quadratic work about 16. A run too fast to time is repeated within a sample, a growth in the band that load reaches is sampled again, a fixed cost both sizes share is measured as a baseline and taken out, and a full-size ceiling still fails catastrophic regressions. Work that a cap keeps linear but slow is checked with expectCostRatio against reference work in the same process, with separate limits with and without coverage. A test that measures growth goes in a *.linear-time.test.ts file and sets LINEAR_TIME_TIMEOUT_MS as its timeout, because Windows counts CPU time in coarse ticks and the check takes seconds there. The helper throws in a test of the unit project, and in a test with a shorter timeout. The language server's tests share the same approach (lsp/src/linear-time.test.util.ts), and so do the agent skills' tests (skills-tests/tests/linear-time.test-helper.ts, which also throws in the unit project).

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.ts drives the source cycle with injected clocks and promise gates to verify phase ordering, overlapping edits, retained changes, failure recovery, and nonblocking parallel work.
  • watch-source.integration.test.ts runs 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 post runs.
  • The exec Playwright lanes hold pre and parallel children behind HTTP release gates. Real CLI build fixtures run in temporary project directories and use an event-driven artifact gate to prove that post sees compiled pages while parallel is still running. Serial and worker builds both wait for parallel to 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.ts and 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. The harness checks every sample itself, so a lane cannot pass by forgetting an assertion: superseded module entries and inline loads, and destroyed sockets, must be collected after GC (each tracked through a WeakRef, and each tracker must have observed something), active resources must match the baseline, and no heap snapshot may hold more Timeout objects than the first, which catches unref'd timers that process.getActiveResourcesInfo() does not list. Node's own HTTP Date header cache timer (resetCache in node:internal/http, identified by its script in the snapshot) is counted apart and may appear at most once: it lives until the next whole second after any response, so whether a snapshot holds it depends only on timing. The HTTP/2 rows need Node 24's TLS cleanup callback ordering; on the Node 24.21 floor they skip and print why, and the dev and HTTP/1.1 rows still run.
  • profile-resource-boundaries.integration.test.ts verifies 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.ts and profile-fixture-tls.integration.test.ts validate 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:

sh
# Type check all packages
yarn typecheck:all

# Package-specific type checks
yarn pkg:typecheck
yarn create:typecheck
yarn docs:typecheck
yarn ext:typecheck
yarn adapter:cf:typecheck
yarn lsp:typecheck
yarn skills:typecheck

Monorepo Aggregator Commands

The root package.json provides aggregated tasks across all projects:

  • yarn typecheck:all: runs typechecks across all packages in the workspace
  • yarn check:all: runs formatting (check:format), spelling (check:spelling), and web standards (check:standards)
  • yarn unit:all: runs unit test suites across all packages
  • yarn integration:all: runs the long-running integration test suite
  • yarn linear-time:all: runs the linear-time checks (pkg, lsp, and skills-tests)
  • yarn e2e:all: runs Playwright E2E suites across the workspace (the four pkg lanes in parallel through yarn pkg:e2e:all, then docs and both adapter lanes)
  • yarn coverage:all: generates and updates coverage reports across all packages
  • yarn test:all: runs typechecks, spelling/standards checks, unit tests, integration tests, linear-time checks, and E2E suites in sequence (coverage excluded)

Contributing a Fix

  1. Fork the repository and create a branch.
  2. Make your changes in pkg/src/.
  3. Add or update tests in the paired *.test.ts file.
  4. Run yarn pkg:unit and ensure all tests pass.
  5. Run yarn pkg:integration if your change touches a real process, server, or worker boundary, and yarn pkg:linear-time if it touches a scan, masking, or rendering loop that a linear-time check covers.
  6. Run yarn pkg:typecheck to confirm there are no TypeScript errors.
  7. Open a pull request against main.