playwright-browser-harness
v0.3.0
Published
Playwright harness that bundles your code, serves it under a COOP/COEP-toggleable server, drives it in a real browser, and returns structured results/timings — for testing real browser-storage persistence (IndexedDB/OPFS), cross-origin isolation, Workers,
Maintainers
Readme
playwright-browser-harness
Run bundled TypeScript inside a real browser under Playwright — drive it,
assert results, measure performance — with the things browser storage needs:
COOP/COEP cross-origin isolation (SharedArrayBuffer / OPFS), Web Workers,
persistence across reloads, a reset between tests.
Hybrid distribution. The machinery (mountHarness/driver, the COOP/COEP
server, the esbuild build, the page glue, the contract) ships in this package and
is imported, never copied. A tiny init CLI scaffolds only the per-project
files you edit anyway (a starter cut.ts, a spec, a playwright.config.ts).
Install
pnpm add -D playwright-browser-harness @playwright/test esbuild typescript @types/node
pnpm exec playwright install chromium@playwright/test and esbuild are peerDependencies — the consumer drives
their versions. @sqlite.org/sqlite-wasm is not a dependency; it is only a
fixture/consumer dep when you test wasm-SQLite.
buffer is an optional peerDependency — only needed (and only installed) when
you ask for the 'buffer' Node polyfill (see
Explicit Node polyfills):
pnpm add -D buffer # only if you use nodePolyfills: ['buffer', ...]tsconfig (required)
{
"compilerOptions": {
"moduleResolution": "bundler", // resolve 'playwright-browser-harness/contract'
"allowImportingTsExtensions": true, // the contract ships as raw .ts and is bundled
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"]
}
}Scaffold a starter (optional)
npx playwright-browser-harness init tests # writes tests/cut.ts, tests/example.spec.ts, playwright.config.ts
pnpm exec playwright test
npx playwright-browser-harness update tests # report drift of scaffolded files vs current templatesNamed exports
The package's . entry exports the high-level driver plus the lower-level
building blocks, all as documented named exports:
import {mountHarness, buildBundle, startServer} from 'playwright-browser-harness';mountHarness(page, opts)— the usual entry point (bundle + serve + drive).buildBundle(opts)— esbuild-bundle acut(+ optional worker) to anoutdir.startServer({root, coi})— the tiny COOP/COEP-toggleable static server.
Use buildBundle / startServer directly when you want to drive bundling and
serving yourself (e.g. a custom Playwright global-setup) instead of prebuilt.
The in-page glue is importable too, so your own bundler can include it:
playwright-browser-harness/contract— raw.tscontract (captureEnv,timed, types).playwright-browser-harness/page-entry— raw.tspage glue (for esbuild/bundler consumers).playwright-browser-harness/page-bootstrap— plain.jspage glue (loadable directly in the browser).
Use
// tests/cut.ts — your code-under-test, BUNDLED INTO THE BROWSER
import type {CodeUnderTest} from 'playwright-browser-harness/contract';
import {captureEnv, timed} from 'playwright-browser-harness/contract';
const cut: CodeUnderTest = { name: 'mine', async run(ctx){ /* ... */ return {results:{},timings:[],errors:[],env:captureEnv()}; } };
export default cut;// tests/x.spec.ts — Node side
import {test, expect} from '@playwright/test';
import {mountHarness} from 'playwright-browser-harness';
test('persists', async ({page}) => {
const h = await mountHarness(page, {cut: resolve(__dirname,'cut.ts'), coi: false});
const w = await h.run({phase:'write', params:{n:100}});
await h.reload();
const r = await h.run({phase:'read', params:{n:100}});
expect(r.results.survived).toBe(true);
await h.dispose();
});MountOptions: cut (esbuild path) or entry/noBundle (prebuilt JS, no
esbuild) or prebuilt (prebuilt dir); coi (default true), worker,
wasmDirs, assets, plus the bundling escape-hatches nodePolyfills and
esbuild (see below).
Explicit Node polyfills
Many web3 / crypto libraries (ethereumjs, tevm, and anything pulling in
readable-stream / safe-buffer) transitively import 'buffer' and touch
process / global / events / stream. esbuild's platform:'browser' will
not resolve those, so the bundle fails with Could not resolve "buffer".
There is no catch-all preset. A named "web3" preset is a leaky abstraction:
it would shim buffer/process/global but not events/stream/util, so
you'd fall back to the esbuild escape-hatch anyway. Instead you declare
exactly what you need.
nodePolyfills?: ('buffer' | 'process' | 'global')[] (default [])
The three Node builtins the harness can shim itself, with zero opinions.
Each entry is a self-contained fragment — ['buffer'] shims ONLY buffer; it
does not touch process or global. [] or omitted = nothing.
| entry | what it does |
|---|---|
| 'buffer' | alias buffer / node:buffer → the buffer npm package, and inject a Buffer global (also assigned onto globalThis). Requires the optional buffer peer dep (pnpm add -D buffer). |
| 'process' | inject a minimal process stub: {env:{}, browser:true, version:'', nextTick}. |
| 'global' | set define: { global: 'globalThis' }. |
Anything beyond these three (events, stream, util, crypto, …) is the
consumer's job via the esbuild
pass-through below.
Copy-paste recipe: ethereumjs / tevm
A typical web3 EVM tree needs all three builtins plus events/stream
aliased through the escape hatch:
const h = await mountHarness(page, {
cut,
coi: false, // pure-compute EVM → no SharedArrayBuffer needed
nodePolyfills: ['buffer', 'process', 'global'], // the harness's three builtins
esbuild: {
// everything the harness intentionally does NOT preset:
alias: {events: 'events', stream: 'stream-browserify'},
},
});pnpm add -D buffer events stream-browserifyMigrating from 0.2.0 —
nodePolyfillsshipped in 0.2.0 astrue | 'web3'. That form was removed (clean break; 0.2.0 had ≈no users). Replacetrue/'web3'with['buffer', 'process', 'global'].
esbuild escape hatch — esbuild?: { plugins?, inject?, define?, alias?, loader?, external?, tsconfig? }
A pass-through merged into the harness's internal esbuild build, so a consumer
whose code-under-test needs a plugin/alias/define/loader no longer has to fork
the bundler (replicating the page-entry glue and mounting via prebuilt).
Consumer values take precedence (objects shallow-merged with the consumer
winning; arrays concatenated after the built-ins). The harness's own
__CUT_MODULE__ resolve plugin, the page-entry entry point, and the
.wasm→copy loader always remain:
| key | merge behaviour |
|---|---|
| plugins | concatenated after the built-in __CUT_MODULE__ resolver |
| inject | concatenated after the nodePolyfills injects (the chosen builtins, if any) |
| define / alias | shallow-merged, consumer key wins |
| loader | merged over {'.wasm':'copy'} (e.g. add {'.svg':'dataurl'}) |
| external | as given |
| tsconfig | path to a tsconfig esbuild should honour |
Doing it entirely by hand (no nodePolyfills)
nodePolyfills is just sugar for these three fragments. If you'd rather not use
it at all (e.g. you want one custom shim module), alias buffer to the npm
package and inject a Buffer/process shim purely via the escape hatch:
const h = await mountHarness(page, {
cut,
coi: false,
esbuild: {
alias: {buffer: 'buffer'}, // the npm `buffer` package
inject: [resolve(__dirname, 'node-shim.js')], // exports Buffer; sets globalThis.Buffer/process
define: {global: 'globalThis'},
},
});// node-shim.js
import {Buffer} from 'buffer';
if (!globalThis.Buffer) globalThis.Buffer = Buffer;
if (!globalThis.process) globalThis.process = {env: {}, browser: true, version: '', nextTick: (cb, ...a) => Promise.resolve().then(() => cb(...a))};
export {Buffer};Testing a prebuilt artifact (no esbuild)
The default path re-bundles your source with the harness's esbuild. To
exercise the exact bytes your own build emitted (tsc / tsup / rollup →
dist/), skip esbuild entirely:
entry / noBundle — wrap one prebuilt JS module
Point the harness at an already-built .js module that default-exports a
CodeUnderTest. The harness serves it verbatim (with its sibling .map,
if present) behind a tiny plain-JS loader — no esbuild, no re-bundle, so source
maps point at your dist/, not a harness re-bundle:
import {mountHarness} from 'playwright-browser-harness';
// your own build already produced dist/cut.js (+ dist/cut.js.map)
const h = await mountHarness(page, {entry: resolve('dist/cut.js'), coi: false});
// equivalently: { cut: resolve('dist/cut.js'), noBundle: true }
const w = await h.run({phase: 'write', params: {n: 100}});
await h.reload();
const r = await h.run({phase: 'read', params: {n: 100}});The served directory contains __cut.js (your module, verbatim), contract.js
(the harness's compiled glue), bootstrap.js (the no-esbuild page glue), and
index.html.
prebuilt — serve a fully-built directory
If your own bundler already produced a complete directory (its own index.html
that boots the harness and sets window.__harness), serve it as-is. The glue is
importable so your bundler can include it:
playwright-browser-harness/page-entry (raw .ts, for bundler consumers) or
playwright-browser-harness/page-bootstrap (plain .js, loadable directly).
// harness starts its own COOP/COEP server for the dir:
const h = await mountHarness(page, {prebuilt: {outdir: resolve('dist/site')}, coi: false});
// or reuse an already-running server (harness won't start/stop one):
const h2 = await mountHarness(page, {prebuilt: {outdir, serverUrl: 'http://127.0.0.1:5173'}});COOP/COEP (coi) vs your wasm executor
coi toggles cross-origin isolation (COOP/COEP response headers →
crossOriginIsolated === true, the precondition for SharedArrayBuffer).
Match it to what your wasm needs:
| Workload | coi | Why |
|---|---|---|
| EVM / pure-compute wasm (ethereumjs, tevm, single-threaded wasm) | false | No SharedArrayBuffer; isolation is pure overhead and can complicate loading. |
| Threaded wasm needing SharedArrayBuffer (wasm threads, OPFS sync VFS, sqlite-wasm opfs) | true | SharedArrayBuffer / the sync OPFS VFS require crossOriginIsolated. |
| Plain IndexedDB / opfs-sahpool | false | Works without isolation; flip true only to test the isolated path. |
Default is coi:true; EVM / pure-compute executors want coi:false.
See the
playwright-browser-test-harness research topic for the full
architecture, gotchas, and per-browser OPFS matrix.
