playwright-browser-harness 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,14 @@ pnpm exec playwright install chromium
21
21
  their versions. `@sqlite.org/sqlite-wasm` is *not* a dependency; it is only a
22
22
  fixture/consumer dep when you test wasm-SQLite.
23
23
 
24
+ `buffer` is an **optional peerDependency** — only needed (and only installed) when
25
+ you ask for the `'buffer'` Node polyfill (see
26
+ [Explicit Node polyfills](#explicit-node-polyfills)):
27
+
28
+ ```bash
29
+ pnpm add -D buffer # only if you use nodePolyfills: ['buffer', ...]
30
+ ```
31
+
24
32
  ## tsconfig (required)
25
33
 
26
34
  ```jsonc
@@ -41,6 +49,28 @@ pnpm exec playwright test
41
49
  npx playwright-browser-harness update tests # report drift of scaffolded files vs current templates
42
50
  ```
43
51
 
52
+ ## Named exports
53
+
54
+ The package's `.` entry exports the high-level driver plus the lower-level
55
+ building blocks, all as documented named exports:
56
+
57
+ ```ts
58
+ import {mountHarness, buildBundle, startServer} from 'playwright-browser-harness';
59
+ ```
60
+
61
+ - `mountHarness(page, opts)` — the usual entry point (bundle + serve + drive).
62
+ - `buildBundle(opts)` — esbuild-bundle a `cut` (+ optional worker) to an `outdir`.
63
+ - `startServer({root, coi})` — the tiny COOP/COEP-toggleable static server.
64
+
65
+ Use `buildBundle` / `startServer` directly when you want to drive bundling and
66
+ serving yourself (e.g. a custom Playwright global-setup) instead of `prebuilt`.
67
+
68
+ The in-page glue is importable too, so your **own** bundler can include it:
69
+
70
+ - `playwright-browser-harness/contract` — raw `.ts` contract (`captureEnv`, `timed`, types).
71
+ - `playwright-browser-harness/page-entry` — raw `.ts` page glue (for esbuild/bundler consumers).
72
+ - `playwright-browser-harness/page-bootstrap` — plain `.js` page glue (loadable directly in the browser).
73
+
44
74
  ## Use
45
75
 
46
76
  ```ts
@@ -66,7 +96,166 @@ test('persists', async ({page}) => {
66
96
  });
67
97
  ```
68
98
 
69
- `MountOptions`: `cut` (required), `coi` (default `true`), `worker`, `wasmDirs`,
70
- `assets`, `prebuilt`. See the
99
+ `MountOptions`: `cut` (esbuild path) **or** `entry`/`noBundle` (prebuilt JS, no
100
+ esbuild) **or** `prebuilt` (prebuilt dir); `coi` (default `true`), `worker`,
101
+ `wasmDirs`, `assets`, plus the bundling escape-hatches `nodePolyfills` and
102
+ `esbuild` (see below).
103
+
104
+ ## Explicit Node polyfills
105
+
106
+ Many web3 / crypto libraries (ethereumjs, tevm, and anything pulling in
107
+ `readable-stream` / `safe-buffer`) transitively `import 'buffer'` and touch
108
+ `process` / `global` / `events` / `stream`. esbuild's `platform:'browser'` will
109
+ **not** resolve those, so the bundle fails with `Could not resolve "buffer"`.
110
+
111
+ There is **no** catch-all preset. A named "web3" preset is a leaky abstraction:
112
+ it would shim `buffer`/`process`/`global` but not `events`/`stream`/`util`, so
113
+ you'd fall back to the `esbuild` escape-hatch anyway. Instead you **declare
114
+ exactly what you need**.
115
+
116
+ ### `nodePolyfills?: ('buffer' | 'process' | 'global')[]` (default `[]`)
117
+
118
+ The three Node builtins the harness can shim **itself**, with zero opinions.
119
+ Each entry is a **self-contained** fragment — `['buffer']` shims ONLY buffer; it
120
+ does **not** touch `process` or `global`. `[]` or omitted = nothing.
121
+
122
+ | entry | what it does |
123
+ |---|---|
124
+ | `'buffer'` | alias `buffer` / `node:buffer` → the [`buffer`](https://www.npmjs.com/package/buffer) npm package, and inject a `Buffer` global (also assigned onto `globalThis`). Requires the optional `buffer` peer dep (`pnpm add -D buffer`). |
125
+ | `'process'` | inject a minimal `process` stub: `{env:{}, browser:true, version:'', nextTick}`. |
126
+ | `'global'` | set `define: { global: 'globalThis' }`. |
127
+
128
+ **Anything beyond these three** (`events`, `stream`, `util`, `crypto`, …) is the
129
+ consumer's job via the [`esbuild`](#esbuild-escape-hatch--esbuild--plugins-inject-define-alias-loader-external-tsconfig-)
130
+ pass-through below.
131
+
132
+ #### Copy-paste recipe: ethereumjs / tevm
133
+
134
+ A typical web3 EVM tree needs all three builtins **plus** `events`/`stream`
135
+ aliased through the escape hatch:
136
+
137
+ ```ts
138
+ const h = await mountHarness(page, {
139
+ cut,
140
+ coi: false, // pure-compute EVM → no SharedArrayBuffer needed
141
+ nodePolyfills: ['buffer', 'process', 'global'], // the harness's three builtins
142
+ esbuild: {
143
+ // everything the harness intentionally does NOT preset:
144
+ alias: {events: 'events', stream: 'stream-browserify'},
145
+ },
146
+ });
147
+ ```
148
+
149
+ ```bash
150
+ pnpm add -D buffer events stream-browserify
151
+ ```
152
+
153
+ > **Migrating from 0.2.0** — `nodePolyfills` shipped in 0.2.0 as `true | 'web3'`.
154
+ > That form was **removed** (clean break; 0.2.0 had ≈no users). Replace
155
+ > `true` / `'web3'` with `['buffer', 'process', 'global']`.
156
+
157
+ ### esbuild escape hatch — `esbuild?: { plugins?, inject?, define?, alias?, loader?, external?, tsconfig? }`
158
+
159
+ A pass-through merged into the harness's internal esbuild build, so a consumer
160
+ whose code-under-test needs a plugin/alias/define/loader no longer has to **fork
161
+ the bundler** (replicating the page-entry glue and mounting via `prebuilt`).
162
+ **Consumer values take precedence** (objects shallow-merged with the consumer
163
+ winning; arrays concatenated **after** the built-ins). The harness's own
164
+ `__CUT_MODULE__` resolve plugin, the page-entry entry point, and the
165
+ `.wasm`→`copy` loader **always remain**:
166
+
167
+ | key | merge behaviour |
168
+ |---|---|
169
+ | `plugins` | concatenated **after** the built-in `__CUT_MODULE__` resolver |
170
+ | `inject` | concatenated after the `nodePolyfills` injects (the chosen builtins, if any) |
171
+ | `define` / `alias` | shallow-merged, **consumer key wins** |
172
+ | `loader` | merged **over** `{'.wasm':'copy'}` (e.g. add `{'.svg':'dataurl'}`) |
173
+ | `external` | as given |
174
+ | `tsconfig` | path to a tsconfig esbuild should honour |
175
+
176
+ #### Doing it entirely by hand (no `nodePolyfills`)
177
+
178
+ `nodePolyfills` is just sugar for these three fragments. If you'd rather not use
179
+ it at all (e.g. you want one custom shim module), alias `buffer` to the npm
180
+ package and inject a `Buffer`/`process` shim purely via the escape hatch:
181
+
182
+ ```ts
183
+ const h = await mountHarness(page, {
184
+ cut,
185
+ coi: false,
186
+ esbuild: {
187
+ alias: {buffer: 'buffer'}, // the npm `buffer` package
188
+ inject: [resolve(__dirname, 'node-shim.js')], // exports Buffer; sets globalThis.Buffer/process
189
+ define: {global: 'globalThis'},
190
+ },
191
+ });
192
+ ```
193
+
194
+ ```js
195
+ // node-shim.js
196
+ import {Buffer} from 'buffer';
197
+ if (!globalThis.Buffer) globalThis.Buffer = Buffer;
198
+ if (!globalThis.process) globalThis.process = {env: {}, browser: true, version: '', nextTick: (cb, ...a) => Promise.resolve().then(() => cb(...a))};
199
+ export {Buffer};
200
+ ```
201
+
202
+ ## Testing a prebuilt artifact (no esbuild)
203
+
204
+ The default path re-bundles your **source** with the harness's esbuild. To
205
+ exercise the **exact bytes your own build emitted** (tsc / tsup / rollup →
206
+ `dist/`), skip esbuild entirely:
207
+
208
+ ### `entry` / `noBundle` — wrap one prebuilt JS module
209
+
210
+ Point the harness at an already-built `.js` module that default-exports a
211
+ `CodeUnderTest`. The harness serves it **verbatim** (with its sibling `.map`,
212
+ if present) behind a tiny plain-JS loader — no esbuild, no re-bundle, so source
213
+ maps point at your `dist/`, not a harness re-bundle:
214
+
215
+ ```ts
216
+ import {mountHarness} from 'playwright-browser-harness';
217
+
218
+ // your own build already produced dist/cut.js (+ dist/cut.js.map)
219
+ const h = await mountHarness(page, {entry: resolve('dist/cut.js'), coi: false});
220
+ // equivalently: { cut: resolve('dist/cut.js'), noBundle: true }
221
+ const w = await h.run({phase: 'write', params: {n: 100}});
222
+ await h.reload();
223
+ const r = await h.run({phase: 'read', params: {n: 100}});
224
+ ```
225
+
226
+ The served directory contains `__cut.js` (your module, verbatim), `contract.js`
227
+ (the harness's compiled glue), `bootstrap.js` (the no-esbuild page glue), and
228
+ `index.html`.
229
+
230
+ ### `prebuilt` — serve a fully-built directory
231
+
232
+ If your own bundler already produced a complete directory (its own `index.html`
233
+ that boots the harness and sets `window.__harness`), serve it as-is. The glue is
234
+ importable so your bundler can include it:
235
+ `playwright-browser-harness/page-entry` (raw `.ts`, for bundler consumers) or
236
+ `playwright-browser-harness/page-bootstrap` (plain `.js`, loadable directly).
237
+
238
+ ```ts
239
+ // harness starts its own COOP/COEP server for the dir:
240
+ const h = await mountHarness(page, {prebuilt: {outdir: resolve('dist/site')}, coi: false});
241
+ // or reuse an already-running server (harness won't start/stop one):
242
+ const h2 = await mountHarness(page, {prebuilt: {outdir, serverUrl: 'http://127.0.0.1:5173'}});
243
+ ```
244
+
245
+ ## COOP/COEP (`coi`) vs your wasm executor
246
+
247
+ `coi` toggles cross-origin isolation (COOP/COEP response headers →
248
+ `crossOriginIsolated === true`, the precondition for `SharedArrayBuffer`).
249
+ Match it to what your wasm needs:
250
+
251
+ | Workload | `coi` | Why |
252
+ |---|---|---|
253
+ | EVM / pure-compute wasm (ethereumjs, tevm, single-threaded wasm) | `false` | No `SharedArrayBuffer`; isolation is pure overhead and can complicate loading. |
254
+ | Threaded wasm needing `SharedArrayBuffer` (wasm threads, OPFS sync VFS, sqlite-wasm `opfs`) | `true` | `SharedArrayBuffer` / the sync OPFS VFS require `crossOriginIsolated`. |
255
+ | Plain IndexedDB / `opfs-sahpool` | `false` | Works without isolation; flip `true` only to test the isolated path. |
256
+
257
+ Default is `coi:true`; **EVM / pure-compute executors want `coi:false`**.
258
+
259
+ See the
71
260
  [`playwright-browser-test-harness` research topic](../README.md) for the full
72
261
  architecture, gotchas, and per-browser OPFS matrix.
package/dist/build.mjs CHANGED
@@ -22,6 +22,131 @@ import { existsSync } from 'node:fs';
22
22
 
23
23
  const here = dirname(fileURLToPath(import.meta.url));
24
24
 
25
+ /**
26
+ * Explicit, composable Node-builtin polyfill fragments.
27
+ *
28
+ * There is intentionally NO catch-all "web3" preset: a named preset is a leaky
29
+ * abstraction — it would shim `buffer`/`process`/`global` but not
30
+ * `events`/`stream`/`util`/`crypto`, so consumers fall back to the `esbuild`
31
+ * escape-hatch anyway. Instead the harness exposes exactly the three builtins it
32
+ * can shim with zero opinions, and each maps to a self-contained fragment:
33
+ *
34
+ * - 'buffer' → alias `buffer`/`node:buffer` to the `buffer` npm package +
35
+ * inject a `Buffer` shim that also assigns `globalThis.Buffer`.
36
+ * - 'process' → inject a minimal `process` stub `{env,browser,version,nextTick}`.
37
+ * - 'global' → define `{ global: 'globalThis' }`.
38
+ *
39
+ * Anything else (`events`, `stream`, `util`, `crypto`, …) is the consumer's job
40
+ * via the `esbuild: { alias, inject, define, plugins }` pass-through.
41
+ *
42
+ * Each helper returns an esbuild *fragment* (`{alias?, inject?, define?}`) so a
43
+ * consumer can compose them with their own esbuild opts. `buffer` is an
44
+ * optional/peer dependency: the consumer installs it when they opt into the
45
+ * `'buffer'` polyfill.
46
+ *
47
+ * @typedef {'buffer'|'process'|'global'} NodePolyfill
48
+ */
49
+
50
+ /**
51
+ * Build a single Node-polyfill fragment by name. Self-contained: passing
52
+ * `'buffer'` shims ONLY buffer, etc. Unknown names throw with a clear message.
53
+ *
54
+ * @param {NodePolyfill} name which builtin to shim
55
+ * @param {string} outdir directory to write any generated shim modules into
56
+ * @returns {Promise<{alias?: Record<string,string>, inject?: string[], define?: Record<string,string>}>}
57
+ */
58
+ export async function nodePolyfill(name, outdir) {
59
+ if (name === 'buffer') {
60
+ // Resolve the `buffer` npm package from the consumer's install via its bare
61
+ // specifier so esbuild aliases `buffer`/`node:buffer` to it. (We don't
62
+ // hard-require it here — esbuild surfaces a clear error if the consumer
63
+ // asked for the 'buffer' polyfill without installing `buffer`.)
64
+ const bufferPkg = 'buffer';
65
+ // A tiny module that re-exports `Buffer` from the `buffer` package so esbuild
66
+ // can `inject` it as the `Buffer` global. We ALSO assign it onto `globalThis`
67
+ // so libraries that reach for `globalThis.Buffer` / `window.Buffer` at
68
+ // runtime (not just an unqualified `Buffer` identifier) find it too. GOTCHA:
69
+ // esbuild's `inject` only provides a top-level *binding* in modules that
70
+ // reference the name — it does not set a real global — so this explicit
71
+ // assignment is what makes `globalThis.Buffer` truthy in-page.
72
+ const bufShim = join(outdir, '__buffer-shim.js');
73
+ await writeFile(
74
+ bufShim,
75
+ [
76
+ "import { Buffer } from 'buffer';",
77
+ 'if (typeof globalThis !== "undefined" && !globalThis.Buffer) globalThis.Buffer = Buffer;',
78
+ 'export { Buffer };',
79
+ ].join('\n'),
80
+ );
81
+ return {
82
+ alias: { buffer: bufferPkg, 'node:buffer': bufferPkg },
83
+ inject: [bufShim],
84
+ };
85
+ }
86
+
87
+ if (name === 'process') {
88
+ // A tiny `process` shim written next to the bundle and injected as a global.
89
+ // Kept minimal on purpose: env bag, browser flag, empty version, and a
90
+ // microtask-based nextTick — enough for readable-stream/safe-buffer & co.
91
+ const procShim = join(outdir, '__process-shim.js');
92
+ await writeFile(
93
+ procShim,
94
+ [
95
+ 'const process = {',
96
+ ' env: {},',
97
+ ' browser: true,',
98
+ " version: '',",
99
+ ' nextTick: (cb, ...args) => Promise.resolve().then(() => cb(...args)),',
100
+ '};',
101
+ 'export { process };',
102
+ 'export default process;',
103
+ ].join('\n'),
104
+ );
105
+ return { inject: [procShim] };
106
+ }
107
+
108
+ if (name === 'global') {
109
+ return { define: { global: 'globalThis' } };
110
+ }
111
+
112
+ throw new Error(
113
+ `nodePolyfill: unknown polyfill ${JSON.stringify(name)} — valid values are 'buffer', 'process', 'global'.`,
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The set of builtins the harness can shim itself, as a reusable/testable map of
119
+ * `name → (outdir) => fragment`. Exposed so a consumer can compose individual
120
+ * fragments with their own esbuild opts without going through `buildBundle`.
121
+ *
122
+ * @type {Record<NodePolyfill, (outdir: string) => Promise<{alias?: Record<string,string>, inject?: string[], define?: Record<string,string>}>>}
123
+ */
124
+ export const nodePolyfills = {
125
+ buffer: (outdir) => nodePolyfill('buffer', outdir),
126
+ process: (outdir) => nodePolyfill('process', outdir),
127
+ global: (outdir) => nodePolyfill('global', outdir),
128
+ };
129
+
130
+ /**
131
+ * Compose the requested Node-polyfill fragments into a single
132
+ * `{alias, inject, define}` bundle. Order-stable; later entries' alias/define
133
+ * keys win on clash (none do today since the three fragments are disjoint).
134
+ *
135
+ * @param {NodePolyfill[]} names
136
+ * @param {string} outdir
137
+ * @returns {Promise<{alias: Record<string,string>, inject: string[], define: Record<string,string>}>}
138
+ */
139
+ async function composeNodePolyfills(names, outdir) {
140
+ const out = { alias: {}, inject: [], define: {} };
141
+ for (const name of names) {
142
+ const frag = await nodePolyfill(name, outdir);
143
+ if (frag.alias) Object.assign(out.alias, frag.alias);
144
+ if (frag.inject) out.inject.push(...frag.inject);
145
+ if (frag.define) Object.assign(out.define, frag.define);
146
+ }
147
+ return out;
148
+ }
149
+
25
150
  /**
26
151
  * @param {object} opts
27
152
  * @param {string} opts.cut absolute path to the code-under-test module
@@ -31,14 +156,74 @@ const here = dirname(fileURLToPath(import.meta.url));
31
156
  * @param {string[]} [opts.assets] explicit absolute file paths to copy verbatim
32
157
  * into outdir (e.g. sqlite3-opfs-async-proxy.js, which the OPFS VFS spawns by
33
158
  * URL relative to the worker bundle and esbuild does NOT trace).
159
+ * @param {Array<'buffer'|'process'|'global'>} [opts.nodePolyfills] explicit,
160
+ * composable list of Node builtins the harness shims itself. `['buffer']`
161
+ * shims ONLY buffer; `[]` or omitted = nothing. Each entry is self-contained:
162
+ * 'buffer' aliases `buffer`/`node:buffer` + injects a `Buffer` global,
163
+ * 'process' injects a minimal `process` stub, 'global' defines
164
+ * `{global:'globalThis'}`. There is NO catch-all preset; everything beyond
165
+ * these three (events, stream, util, crypto, …) goes through `opts.esbuild`.
166
+ * @param {object} [opts.esbuild] esbuild pass-through merged into the internal
167
+ * build. Consumer values take precedence; arrays are concatenated AFTER the
168
+ * built-ins so the `__CUT_MODULE__` resolver, the page-entry entry point, and
169
+ * the `.wasm` copy loader always remain.
170
+ * @param {import('esbuild').Plugin[]} [opts.esbuild.plugins]
171
+ * @param {string[]} [opts.esbuild.inject]
172
+ * @param {Record<string,string>} [opts.esbuild.define]
173
+ * @param {Record<string,string>} [opts.esbuild.alias]
174
+ * @param {Record<string,string>} [opts.esbuild.loader] merged OVER `{'.wasm':'copy'}`
175
+ * @param {string[]} [opts.esbuild.external]
176
+ * @param {string} [opts.esbuild.tsconfig] path to a tsconfig esbuild should honour
34
177
  */
35
- export async function buildBundle({ cut, outdir, worker, wasmDirs = [], assets = [] }) {
178
+ export async function buildBundle({
179
+ cut,
180
+ outdir,
181
+ worker,
182
+ wasmDirs = [],
183
+ assets = [],
184
+ nodePolyfills = [],
185
+ esbuild: esbuildOpts = {},
186
+ }) {
187
+ if (!Array.isArray(nodePolyfills)) {
188
+ throw new TypeError(
189
+ `buildBundle: \`nodePolyfills\` must be an array of 'buffer' | 'process' | 'global' ` +
190
+ `(got ${JSON.stringify(nodePolyfills)}). The 0.2.0 \`true\`/'web3' form was removed; ` +
191
+ `migrate to e.g. ['buffer','process','global'].`,
192
+ );
193
+ }
36
194
  await rm(outdir, { recursive: true, force: true });
37
195
  await mkdir(outdir, { recursive: true });
38
196
 
39
197
  const entryPoints = [{ in: join(here, 'page-entry.ts'), out: 'bundle' }];
40
198
  if (worker) entryPoints.push({ in: worker, out: 'worker' });
41
199
 
200
+ // The built-in __CUT_MODULE__ resolver plugin must ALWAYS run, even when the
201
+ // consumer supplies their own plugins (concat, never clobber).
202
+ const cutResolver = {
203
+ name: 'resolve-cut',
204
+ setup(b) {
205
+ b.onResolve({ filter: /^__CUT_MODULE__$/ }, () => ({ path: cut }));
206
+ },
207
+ };
208
+
209
+ // Compose the explicitly-requested Node-builtin polyfill fragments (default
210
+ // none). Produces alias/inject/define merged UNDER the consumer's explicit
211
+ // esbuild opts (so the consumer can still override any specific key).
212
+ const preset =
213
+ nodePolyfills.length > 0
214
+ ? await composeNodePolyfills(nodePolyfills, outdir)
215
+ : null;
216
+
217
+ const {
218
+ plugins: userPlugins = [],
219
+ inject: userInject = [],
220
+ define: userDefine = {},
221
+ alias: userAlias = {},
222
+ loader: userLoader = {},
223
+ external: userExternal = [],
224
+ tsconfig: userTsconfig,
225
+ } = esbuildOpts;
226
+
42
227
  await esbuild.build({
43
228
  entryPoints,
44
229
  outdir,
@@ -47,18 +232,19 @@ export async function buildBundle({ cut, outdir, worker, wasmDirs = [], assets =
47
232
  target: 'es2022',
48
233
  platform: 'browser',
49
234
  sourcemap: true,
50
- // Inject the chosen code-under-test as the `__CUT_MODULE__` import.
51
- plugins: [
52
- {
53
- name: 'resolve-cut',
54
- setup(b) {
55
- b.onResolve({ filter: /^__CUT_MODULE__$/ }, () => ({ path: cut }));
56
- },
57
- },
58
- ],
235
+ // Merge order: built-ins polyfills consumer. Consumer wins on key clashes
236
+ // (define/alias/loader) and runs last (plugins). Arrays are concatenated.
237
+ plugins: [cutResolver, ...userPlugins],
238
+ inject: [...(preset?.inject ?? []), ...userInject],
239
+ define: { ...(preset?.define ?? {}), ...userDefine },
240
+ alias: { ...(preset?.alias ?? {}), ...userAlias },
241
+ external: [...userExternal],
242
+ ...(userTsconfig ? { tsconfig: userTsconfig } : {}),
59
243
  // sqlite-wasm ships an mjs that references the wasm by URL; keep it external
60
244
  // to the worker if needed — here we let esbuild bundle JS and copy wasm.
61
- loader: { '.wasm': 'copy' },
245
+ // Consumer loaders merge OVER this (e.g. add `.svg`), but `.wasm` stays copy
246
+ // unless the consumer deliberately overrides that exact key.
247
+ loader: { '.wasm': 'copy', ...userLoader },
62
248
  });
63
249
 
64
250
  // Copy wasm assets (e.g. @sqlite.org/sqlite-wasm's sqlite3.wasm) next to JS.
package/dist/driver.d.ts CHANGED
@@ -27,11 +27,38 @@
27
27
  * env assertions, and ferrying `{results,timings,errors,env}` across the bridge.
28
28
  */
29
29
  import type { Page } from '@playwright/test';
30
+ import { buildBundle } from './build.mjs';
31
+ import { startServer } from './server.mjs';
30
32
  import type { EnvInfo, RunContext, RunResult } from './contract.ts';
31
33
  declare const here: string;
32
34
  export interface MountOptions {
33
- /** Absolute path to the code-under-test module (default-exports CodeUnderTest). */
34
- cut: string;
35
+ /**
36
+ * Absolute path to the code-under-test module (default-exports CodeUnderTest).
37
+ *
38
+ * - **Default (esbuild) path:** point at your SOURCE module; the harness
39
+ * esbuild-bundles it with the page-entry glue.
40
+ * - **Prebuilt-entry path** (`noBundle: true` or use `entry`): point at an
41
+ * ALREADY-BUILT `.js` module (your own tsc/tsup/rollup output). The harness
42
+ * serves it verbatim behind a tiny no-esbuild loader — you exercise the
43
+ * exact bytes your build emitted.
44
+ *
45
+ * Optional when `entry` or `prebuilt` is supplied.
46
+ */
47
+ cut?: string;
48
+ /**
49
+ * Absolute path to an ALREADY-BUILT `.js` CodeUnderTest module to load with
50
+ * **no esbuild**. Equivalent to `{ cut, noBundle: true }`; provided as a
51
+ * clearer name for the prebuilt-artifact path. The file is copied verbatim
52
+ * (with its sibling `.map` if present) and wrapped by the harness's plain-JS
53
+ * page-bootstrap glue. Mutually exclusive with the esbuild path.
54
+ */
55
+ entry?: string;
56
+ /**
57
+ * Skip esbuild entirely and treat `cut`/`entry` as a prebuilt `.js` module
58
+ * (see `entry`). Default `false` (the esbuild path). `worker` / `nodePolyfills` /
59
+ * `esbuild` options are ignored in this mode.
60
+ */
61
+ noBundle?: boolean;
35
62
  /** Serve with COOP/COEP (cross-origin isolation). Default true. */
36
63
  coi?: boolean;
37
64
  /** Optional absolute path to a Web Worker entry to bundle alongside. */
@@ -41,10 +68,67 @@ export interface MountOptions {
41
68
  /** Explicit asset files to copy verbatim next to the bundle (e.g. the OPFS
42
69
  * async proxy js). */
43
70
  assets?: string[];
44
- /** Reuse a prebuilt outdir + server (skip building). */
71
+ /**
72
+ * Explicit, composable Node-builtin polyfills. Declare exactly what your
73
+ * code-under-test needs — there is **no** catch-all preset. Default `[]`
74
+ * (nothing); omitted is the same as `[]`.
75
+ *
76
+ * The three builtins the harness can shim itself (with zero opinions):
77
+ * - `'buffer'` — alias `buffer` / `node:buffer` to the [`buffer`] npm
78
+ * package and inject a `Buffer` global (also assigned onto `globalThis`).
79
+ * Requires the optional `buffer` peer dependency to be installed.
80
+ * - `'process'` — inject a minimal `process` stub
81
+ * (`{ env: {}, browser: true, version: '', nextTick }`).
82
+ * - `'global'` — set `define: { global: 'globalThis' }`.
83
+ *
84
+ * Each entry is self-contained: `['buffer']` shims ONLY buffer and does NOT
85
+ * touch `process` or `global`. Anything beyond these three (`events`,
86
+ * `stream`, `util`, `crypto`, …) is your job via `esbuild.{alias,inject,
87
+ * define,plugins}` below.
88
+ *
89
+ * web3 / crypto trees (ethereumjs, tevm, anything pulling `readable-stream` /
90
+ * `safe-buffer`) typically want `['buffer','process','global']` plus an
91
+ * `esbuild.alias` for `events`/`stream`. See README "Explicit Node polyfills".
92
+ *
93
+ * **Migration from 0.2.0:** the old `true` / `'web3'` form was removed (clean
94
+ * break — 0.2.0 had ~no users). Replace `true`/`'web3'` with
95
+ * `['buffer','process','global']`.
96
+ *
97
+ * [`buffer`]: https://www.npmjs.com/package/buffer
98
+ */
99
+ nodePolyfills?: Array<'buffer' | 'process' | 'global'>;
100
+ /**
101
+ * esbuild pass-through merged into the harness's internal browser build.
102
+ * Consumer values take precedence; arrays are concatenated AFTER the
103
+ * built-ins, so the `__CUT_MODULE__` resolver and the `.wasm` copy loader
104
+ * always remain. Combine with `nodePolyfills` for web3 code that still needs
105
+ * extra builtins (e.g. `alias: { events: 'events', stream: 'stream-browserify' }`).
106
+ */
107
+ esbuild?: {
108
+ plugins?: import('esbuild').Plugin[];
109
+ inject?: string[];
110
+ define?: Record<string, string>;
111
+ alias?: Record<string, string>;
112
+ /** Merged OVER the built-in `{'.wasm':'copy'}` loader. */
113
+ loader?: Record<string, import('esbuild').Loader>;
114
+ external?: string[];
115
+ /** Path to a tsconfig esbuild should honour while bundling. */
116
+ tsconfig?: string;
117
+ };
118
+ /**
119
+ * Serve a FULLY-prebuilt directory as-is (no esbuild, no glue injection). The
120
+ * directory must already contain an `index.html` that boots the harness and
121
+ * sets `window.__harness` (e.g. produced by a consumer's own bundler that
122
+ * imported `playwright-browser-harness/page-entry`).
123
+ *
124
+ * - `serverUrl` omitted → the harness starts its own COOP/COEP server for
125
+ * `outdir` (honouring `coi`/`headers`/`mime`).
126
+ * - `serverUrl` provided → the harness reuses that already-running server and
127
+ * does not start or stop one.
128
+ */
45
129
  prebuilt?: {
46
130
  outdir: string;
47
- serverUrl: string;
131
+ serverUrl?: string;
48
132
  };
49
133
  }
50
134
  export interface MountedHarness {
@@ -58,3 +142,4 @@ export interface MountedHarness {
58
142
  }
59
143
  export declare function mountHarness(page: Page, opts: MountOptions): Promise<MountedHarness>;
60
144
  export { here as harnessDir };
145
+ export { buildBundle, startServer };
package/dist/driver.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url';
2
- import { dirname, join } from 'node:path';
3
- import { mkdtemp } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { mkdtemp, mkdir, copyFile, writeFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
4
5
  import { tmpdir } from 'node:os';
5
6
  // build.mjs / server.mjs are JS ESM siblings; imported at runtime by Playwright.
6
7
  // @ts-ignore - .mjs has no types
@@ -12,11 +13,33 @@ export async function mountHarness(page, opts) {
12
13
  let outdir;
13
14
  let serverUrl;
14
15
  let close;
16
+ const entry = opts.entry ?? (opts.noBundle ? opts.cut : undefined);
15
17
  if (opts.prebuilt) {
18
+ // (b) Serve a fully-prebuilt directory as-is. No esbuild, no glue.
16
19
  outdir = opts.prebuilt.outdir;
17
- serverUrl = opts.prebuilt.serverUrl;
20
+ if (opts.prebuilt.serverUrl) {
21
+ serverUrl = opts.prebuilt.serverUrl;
22
+ }
23
+ else {
24
+ const srv = await startServer({ root: outdir, coi: opts.coi ?? true });
25
+ serverUrl = srv.url;
26
+ close = srv.close;
27
+ }
28
+ }
29
+ else if (entry) {
30
+ // (a) Prebuilt-entry path: wrap an ALREADY-BUILT .js module with the plain-JS
31
+ // page-bootstrap glue — NO esbuild. Exercises the exact built bytes.
32
+ outdir = await mkdtemp(join(tmpdir(), 'harness-'));
33
+ await wrapPrebuiltEntry({ entry, outdir, assets: opts.assets ?? [] });
34
+ const srv = await startServer({ root: outdir, coi: opts.coi ?? true });
35
+ serverUrl = srv.url;
36
+ close = srv.close;
18
37
  }
19
38
  else {
39
+ // Default esbuild path.
40
+ if (!opts.cut) {
41
+ throw new Error('mountHarness: provide `cut` (esbuild path), `entry`/`noBundle` (prebuilt JS), or `prebuilt` (prebuilt dir).');
42
+ }
20
43
  outdir = await mkdtemp(join(tmpdir(), 'harness-'));
21
44
  await buildBundle({
22
45
  cut: opts.cut,
@@ -24,6 +47,8 @@ export async function mountHarness(page, opts) {
24
47
  worker: opts.worker,
25
48
  wasmDirs: opts.wasmDirs ?? [],
26
49
  assets: opts.assets ?? [],
50
+ nodePolyfills: opts.nodePolyfills ?? [],
51
+ esbuild: opts.esbuild ?? {},
27
52
  });
28
53
  const srv = await startServer({ root: outdir, coi: opts.coi ?? true });
29
54
  serverUrl = srv.url;
@@ -50,4 +75,51 @@ async function loadAndWait(page, url) {
50
75
  await page.goto(url, { waitUntil: 'load' });
51
76
  await page.waitForFunction(() => window.__harness?.ready === true);
52
77
  }
78
+ /**
79
+ * Prebuilt-entry path: serve an ALREADY-BUILT `.js` CodeUnderTest module behind
80
+ * the harness's plain-JS page-bootstrap glue, with **no esbuild**.
81
+ *
82
+ * It writes a fresh `outdir` containing:
83
+ * - `__cut.js` the consumer's built module, copied verbatim (+ `.map`),
84
+ * - `contract.js` the harness's compiled contract glue (`captureEnv`),
85
+ * - `bootstrap.js` the page-bootstrap glue (imports the two above, sets
86
+ * `window.__harness`),
87
+ * - `index.html` a `type=module` shell loading `./bootstrap.js`.
88
+ *
89
+ * Because nothing is re-bundled, the served `__cut.js` is byte-for-byte the file
90
+ * your build produced — sourcemaps/line numbers point at YOUR dist, not a
91
+ * harness re-bundle.
92
+ */
93
+ async function wrapPrebuiltEntry({ entry, outdir, assets, }) {
94
+ await mkdir(outdir, { recursive: true });
95
+ // Copy the consumer's built module verbatim as `__cut.js` (+ sibling sourcemap
96
+ // if present, so source maps keep resolving).
97
+ await copyFile(entry, join(outdir, '__cut.js'));
98
+ const map = entry + '.map';
99
+ if (existsSync(map))
100
+ await copyFile(map, join(outdir, '__cut.js.map'));
101
+ // Copy the harness's compiled glue siblings (live next to this driver.js in
102
+ // dist/). These are plain JS the browser loads directly — no bundling.
103
+ await copyFile(join(here, 'contract.js'), join(outdir, 'contract.js'));
104
+ await copyFile(join(here, 'page-bootstrap.js'), join(outdir, 'bootstrap.js'));
105
+ // Copy any explicit assets verbatim (same semantics as the esbuild path).
106
+ for (const a of assets) {
107
+ if (!existsSync(a))
108
+ continue;
109
+ await copyFile(a, join(outdir, basename(a)));
110
+ }
111
+ const html = `<!doctype html>
112
+ <html>
113
+ <head><meta charset="utf-8"><title>harness (prebuilt)</title></head>
114
+ <body>
115
+ <script type="module" src="./bootstrap.js"></script>
116
+ </body>
117
+ </html>`;
118
+ await writeFile(join(outdir, 'index.html'), html);
119
+ }
53
120
  export { here as harnessDir };
121
+ // Re-export the lower-level building blocks as documented named exports so a
122
+ // consumer can drive bundling/serving directly (e.g. a custom global-setup, or
123
+ // re-implementing the mount flow with extra steps) without reaching into the
124
+ // package's internal `.mjs` siblings.
125
+ export { buildBundle, startServer };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * page-bootstrap.js — the NO-ESBUILD in-page glue.
3
+ *
4
+ * This is the plain-JS sibling of `page-entry.ts`. Where `page-entry.ts` is
5
+ * BUNDLED by esbuild together with the code-under-test (the default path), this
6
+ * file is served VERBATIM to the browser and loaded as a native ES module. It
7
+ * imports two things by relative URL at runtime:
8
+ *
9
+ * - `./__cut.js` the consumer's ALREADY-BUILT CodeUnderTest module
10
+ * (their own tsc/tsup/rollup output, copied in as-is),
11
+ * - `./contract.js` the harness's compiled contract glue (`captureEnv`).
12
+ *
13
+ * It then exposes `window.__harness` exactly like `page-entry.ts` does, so the
14
+ * Node-side driver (`run`/`reset`/`env`/`reload`) is identical across both
15
+ * paths. The point: you exercise the EXACT bytes your build emitted — no
16
+ * re-bundling, no second esbuild pass, source maps point at your dist file.
17
+ *
18
+ * The two relative specifiers are rewritten by the harness when it writes the
19
+ * served directory (see driver.ts `wrapPrebuiltEntry`).
20
+ */
21
+ import { captureEnv } from './contract.js';
22
+ // The consumer's prebuilt CodeUnderTest module, copied into the served dir.
23
+ import cut from './__cut.js';
24
+
25
+ const mod = cut;
26
+
27
+ window.__harness = {
28
+ env: () => captureEnv(),
29
+ async run(ctx) {
30
+ try {
31
+ return await mod.run(ctx);
32
+ } catch (e) {
33
+ return {
34
+ results: {},
35
+ timings: [],
36
+ errors: [String(e instanceof Error ? (e.stack ?? e.message) : e)],
37
+ env: captureEnv(),
38
+ };
39
+ }
40
+ },
41
+ async reset() {
42
+ await mod.reset?.();
43
+ },
44
+ ready: true,
45
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-browser-harness",
3
- "version": "0.0.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "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, and perf that Node can't fake. Importable machinery + a tiny `init` CLI for the per-project files.",
6
6
  "license": "MIT",
@@ -25,11 +25,19 @@
25
25
  "types": "./dist/contract.ts",
26
26
  "default": "./dist/contract.ts"
27
27
  },
28
+ "./page-entry": {
29
+ "types": "./dist/page-entry.ts",
30
+ "default": "./dist/page-entry.ts"
31
+ },
32
+ "./page-bootstrap": {
33
+ "default": "./dist/page-bootstrap.js"
34
+ },
28
35
  "./package.json": "./package.json"
29
36
  },
30
37
  "scripts": {
31
38
  "build": "node build.mjs",
32
- "prepack": "node build.mjs"
39
+ "prepack": "node build.mjs",
40
+ "test": "playwright test"
33
41
  },
34
42
  "files": [
35
43
  "dist",
@@ -39,13 +47,26 @@
39
47
  ],
40
48
  "peerDependencies": {
41
49
  "@playwright/test": ">=1.40.0",
50
+ "buffer": ">=6.0.0",
42
51
  "esbuild": ">=0.20.0"
43
52
  },
53
+ "peerDependenciesMeta": {
54
+ "buffer": {
55
+ "optional": true
56
+ }
57
+ },
44
58
  "devDependencies": {
45
- "typescript": "^6.0.3",
46
- "@types/node": "^25.9.1",
59
+ "@ethereumjs/common": "^4.4.0",
60
+ "@ethereumjs/evm": "^3.1.1",
61
+ "@ethereumjs/statemanager": "^2.4.0",
62
+ "@ethereumjs/util": "^9.1.0",
47
63
  "@playwright/test": "^1.60.0",
48
- "esbuild": "^0.28.0"
64
+ "@types/node": "^25.9.1",
65
+ "buffer": "^6.0.3",
66
+ "esbuild": "^0.28.0",
67
+ "events": "^3.3.0",
68
+ "stream-browserify": "^3.0.0",
69
+ "typescript": "^6.0.3"
49
70
  },
50
71
  "keywords": [
51
72
  "playwright",