playwright-browser-harness 0.0.1 → 0.2.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 bundle web3 / Node-dependency-heavy code with `nodePolyfills` (see
26
+ [Bundling web3 / Node-dependency-heavy code](#bundling-web3--node-dependency-heavy-code)):
27
+
28
+ ```bash
29
+ pnpm add -D buffer # only if you use nodePolyfills
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,147 @@ 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
+ ## Bundling web3 / Node-dependency-heavy code
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`. esbuild's `platform:'browser'` will **not** resolve those,
109
+ so the bundle fails with `Could not resolve "buffer"`. The harness gives you two
110
+ opt-in escape-hatches, both **off/empty by default** (existing consumers are
111
+ unchanged):
112
+
113
+ ```ts
114
+ const h = await mountHarness(page, {
115
+ cut,
116
+ coi: false, // pure-compute EVM → no SharedArrayBuffer needed
117
+ nodePolyfills: 'web3', // alias buffer, inject Buffer, shim process, define global
118
+ esbuild: { // pass-through merged into the internal esbuild build
119
+ alias: {events: 'events', stream: 'stream-browserify'}, // any extra builtins
120
+ },
121
+ });
122
+ ```
123
+
124
+ ### `nodePolyfills?: boolean | 'web3'` (default `false`)
125
+
126
+ When enabled it:
127
+
128
+ - aliases `buffer` / `node:buffer` → the [`buffer`](https://www.npmjs.com/package/buffer) npm package,
129
+ - injects a `Buffer` global (also assigned onto `globalThis`),
130
+ - provides a minimal `process` stub: `{env:{}, browser:true, version:'', nextTick}`,
131
+ - sets `define: { global: 'globalThis' }`.
132
+
133
+ `true` and `'web3'` are equivalent today (the string leaves room for future
134
+ presets). **Install the optional `buffer` peer dep** when you opt in
135
+ (`pnpm add -D buffer`). web3 / crypto libraries usually need this; plain DOM /
136
+ storage code does not.
137
+
138
+ ### esbuild escape hatch — `esbuild?: { plugins?, inject?, define?, alias?, loader?, external?, tsconfig? }`
139
+
140
+ A pass-through merged into the harness's internal esbuild build, so a consumer
141
+ whose code-under-test needs a plugin/alias/define/loader no longer has to **fork
142
+ the bundler** (replicating the page-entry glue and mounting via `prebuilt`).
143
+ **Consumer values take precedence** (objects shallow-merged with the consumer
144
+ winning; arrays concatenated **after** the built-ins). The harness's own
145
+ `__CUT_MODULE__` resolve plugin, the page-entry entry point, and the
146
+ `.wasm`→`copy` loader **always remain**:
147
+
148
+ | key | merge behaviour |
149
+ |---|---|
150
+ | `plugins` | concatenated **after** the built-in `__CUT_MODULE__` resolver |
151
+ | `inject` | concatenated after the `nodePolyfills` injects (if any) |
152
+ | `define` / `alias` | shallow-merged, **consumer key wins** |
153
+ | `loader` | merged **over** `{'.wasm':'copy'}` (e.g. add `{'.svg':'dataurl'}`) |
154
+ | `external` | as given |
155
+ | `tsconfig` | path to a tsconfig esbuild should honour |
156
+
157
+ #### Node polyfills recipe (the common case)
158
+
159
+ The quickest path is the `nodePolyfills` preset above. To do it by hand (or to
160
+ extend it), alias `buffer` to the npm package and inject a `Buffer`/`process`
161
+ shim via the escape hatch:
162
+
163
+ ```ts
164
+ const h = await mountHarness(page, {
165
+ cut,
166
+ coi: false,
167
+ esbuild: {
168
+ alias: {buffer: 'buffer'}, // the npm `buffer` package
169
+ inject: [resolve(__dirname, 'node-shim.js')], // exports Buffer; sets globalThis.Buffer/process
170
+ define: {global: 'globalThis'},
171
+ },
172
+ });
173
+ ```
174
+
175
+ ```js
176
+ // node-shim.js
177
+ import {Buffer} from 'buffer';
178
+ if (!globalThis.Buffer) globalThis.Buffer = Buffer;
179
+ if (!globalThis.process) globalThis.process = {env: {}, browser: true, version: '', nextTick: (cb, ...a) => Promise.resolve().then(() => cb(...a))};
180
+ export {Buffer};
181
+ ```
182
+
183
+ ## Testing a prebuilt artifact (no esbuild)
184
+
185
+ The default path re-bundles your **source** with the harness's esbuild. To
186
+ exercise the **exact bytes your own build emitted** (tsc / tsup / rollup →
187
+ `dist/`), skip esbuild entirely:
188
+
189
+ ### `entry` / `noBundle` — wrap one prebuilt JS module
190
+
191
+ Point the harness at an already-built `.js` module that default-exports a
192
+ `CodeUnderTest`. The harness serves it **verbatim** (with its sibling `.map`,
193
+ if present) behind a tiny plain-JS loader — no esbuild, no re-bundle, so source
194
+ maps point at your `dist/`, not a harness re-bundle:
195
+
196
+ ```ts
197
+ import {mountHarness} from 'playwright-browser-harness';
198
+
199
+ // your own build already produced dist/cut.js (+ dist/cut.js.map)
200
+ const h = await mountHarness(page, {entry: resolve('dist/cut.js'), coi: false});
201
+ // equivalently: { cut: resolve('dist/cut.js'), noBundle: true }
202
+ const w = await h.run({phase: 'write', params: {n: 100}});
203
+ await h.reload();
204
+ const r = await h.run({phase: 'read', params: {n: 100}});
205
+ ```
206
+
207
+ The served directory contains `__cut.js` (your module, verbatim), `contract.js`
208
+ (the harness's compiled glue), `bootstrap.js` (the no-esbuild page glue), and
209
+ `index.html`.
210
+
211
+ ### `prebuilt` — serve a fully-built directory
212
+
213
+ If your own bundler already produced a complete directory (its own `index.html`
214
+ that boots the harness and sets `window.__harness`), serve it as-is. The glue is
215
+ importable so your bundler can include it:
216
+ `playwright-browser-harness/page-entry` (raw `.ts`, for bundler consumers) or
217
+ `playwright-browser-harness/page-bootstrap` (plain `.js`, loadable directly).
218
+
219
+ ```ts
220
+ // harness starts its own COOP/COEP server for the dir:
221
+ const h = await mountHarness(page, {prebuilt: {outdir: resolve('dist/site')}, coi: false});
222
+ // or reuse an already-running server (harness won't start/stop one):
223
+ const h2 = await mountHarness(page, {prebuilt: {outdir, serverUrl: 'http://127.0.0.1:5173'}});
224
+ ```
225
+
226
+ ## COOP/COEP (`coi`) vs your wasm executor
227
+
228
+ `coi` toggles cross-origin isolation (COOP/COEP response headers →
229
+ `crossOriginIsolated === true`, the precondition for `SharedArrayBuffer`).
230
+ Match it to what your wasm needs:
231
+
232
+ | Workload | `coi` | Why |
233
+ |---|---|---|
234
+ | EVM / pure-compute wasm (ethereumjs, tevm, single-threaded wasm) | `false` | No `SharedArrayBuffer`; isolation is pure overhead and can complicate loading. |
235
+ | Threaded wasm needing `SharedArrayBuffer` (wasm threads, OPFS sync VFS, sqlite-wasm `opfs`) | `true` | `SharedArrayBuffer` / the sync OPFS VFS require `crossOriginIsolated`. |
236
+ | Plain IndexedDB / `opfs-sahpool` | `false` | Works without isolation; flip `true` only to test the isolated path. |
237
+
238
+ Default is `coi:true`; **EVM / pure-compute executors want `coi:false`**.
239
+
240
+ See the
71
241
  [`playwright-browser-test-harness` research topic](../README.md) for the full
72
242
  architecture, gotchas, and per-browser OPFS matrix.
package/dist/build.mjs CHANGED
@@ -22,6 +22,74 @@ import { existsSync } from 'node:fs';
22
22
 
23
23
  const here = dirname(fileURLToPath(import.meta.url));
24
24
 
25
+ /**
26
+ * Build the `nodePolyfills` preset (esbuild fragments) for browser bundles of
27
+ * web3/crypto code whose dependency trees reach for Node builtins.
28
+ *
29
+ * Many EVM / crypto libraries (ethereumjs, tevm, anything pulling
30
+ * `readable-stream`/`safe-buffer`) transitively `import 'buffer'` and touch
31
+ * `process` / `global`, which esbuild's `platform:'browser'` will NOT resolve —
32
+ * the bundle fails with `Could not resolve "buffer"`. This opt-in preset:
33
+ * - aliases `buffer` / `node:buffer` to the `buffer` npm package,
34
+ * - injects a `Buffer` global (so `Buffer.from(...)` works without an import),
35
+ * - shims a minimal `process` (`{env,browser,version,nextTick}`),
36
+ * - defines `global` → `globalThis`.
37
+ *
38
+ * `buffer` is an optional/peer dependency: the consumer installs it when they
39
+ * opt in (and the harness's own ethereumjs fixture pulls it in for the test).
40
+ *
41
+ * @param {string} outdir directory to write the generated `process` shim into
42
+ * @returns {Promise<{alias: Record<string,string>, inject: string[], define: Record<string,string>}>}
43
+ */
44
+ async function nodePolyfillsPreset(outdir) {
45
+ // Resolve the `buffer` npm package from the consumer's install. We resolve
46
+ // the *bare specifier* so esbuild aliases `buffer`/`node:buffer` to it.
47
+ // (We don't hard-require it here — esbuild will surface a clear error if the
48
+ // consumer enabled nodePolyfills without installing `buffer`.)
49
+ const bufferPkg = 'buffer';
50
+
51
+ // A tiny `process` shim written next to the bundle and injected as a global.
52
+ // Kept minimal on purpose: env bag, browser flag, empty version, and a
53
+ // microtask-based nextTick — enough for readable-stream/safe-buffer & friends.
54
+ const procShim = join(outdir, '__process-shim.js');
55
+ await writeFile(
56
+ procShim,
57
+ [
58
+ 'const process = {',
59
+ ' env: {},',
60
+ ' browser: true,',
61
+ " version: '',",
62
+ ' nextTick: (cb, ...args) => Promise.resolve().then(() => cb(...args)),',
63
+ '};',
64
+ 'export { process };',
65
+ 'export default process;',
66
+ ].join('\n'),
67
+ );
68
+
69
+ // A tiny module that re-exports `Buffer` from the `buffer` package so esbuild
70
+ // can `inject` it as the `Buffer` global. We ALSO assign it onto `globalThis`
71
+ // so libraries that reach for `globalThis.Buffer` / `window.Buffer` at runtime
72
+ // (not just an unqualified `Buffer` identifier) find it too. GOTCHA: esbuild's
73
+ // `inject` only provides a top-level *binding* in modules that reference the
74
+ // name — it does not set a real global — so this explicit assignment is what
75
+ // makes `globalThis.Buffer` truthy in-page.
76
+ const bufShim = join(outdir, '__buffer-shim.js');
77
+ await writeFile(
78
+ bufShim,
79
+ [
80
+ "import { Buffer } from 'buffer';",
81
+ 'if (typeof globalThis !== "undefined" && !globalThis.Buffer) globalThis.Buffer = Buffer;',
82
+ 'export { Buffer };',
83
+ ].join('\n'),
84
+ );
85
+
86
+ return {
87
+ alias: { buffer: bufferPkg, 'node:buffer': bufferPkg },
88
+ inject: [bufShim, procShim],
89
+ define: { global: 'globalThis' },
90
+ };
91
+ }
92
+
25
93
  /**
26
94
  * @param {object} opts
27
95
  * @param {string} opts.cut absolute path to the code-under-test module
@@ -31,14 +99,61 @@ const here = dirname(fileURLToPath(import.meta.url));
31
99
  * @param {string[]} [opts.assets] explicit absolute file paths to copy verbatim
32
100
  * into outdir (e.g. sqlite3-opfs-async-proxy.js, which the OPFS VFS spawns by
33
101
  * URL relative to the worker bundle and esbuild does NOT trace).
102
+ * @param {boolean|'web3'} [opts.nodePolyfills] opt-in Node-builtin polyfills for
103
+ * web3/crypto code (aliases `buffer`/`node:buffer`, injects `Buffer`, shims
104
+ * `process`, defines `global`). Default off. `true` and `'web3'` are
105
+ * equivalent today (the string leaves room for future presets).
106
+ * @param {object} [opts.esbuild] esbuild pass-through merged into the internal
107
+ * build. Consumer values take precedence; arrays are concatenated AFTER the
108
+ * built-ins so the `__CUT_MODULE__` resolver, the page-entry entry point, and
109
+ * the `.wasm` copy loader always remain.
110
+ * @param {import('esbuild').Plugin[]} [opts.esbuild.plugins]
111
+ * @param {string[]} [opts.esbuild.inject]
112
+ * @param {Record<string,string>} [opts.esbuild.define]
113
+ * @param {Record<string,string>} [opts.esbuild.alias]
114
+ * @param {Record<string,string>} [opts.esbuild.loader] merged OVER `{'.wasm':'copy'}`
115
+ * @param {string[]} [opts.esbuild.external]
116
+ * @param {string} [opts.esbuild.tsconfig] path to a tsconfig esbuild should honour
34
117
  */
35
- export async function buildBundle({ cut, outdir, worker, wasmDirs = [], assets = [] }) {
118
+ export async function buildBundle({
119
+ cut,
120
+ outdir,
121
+ worker,
122
+ wasmDirs = [],
123
+ assets = [],
124
+ nodePolyfills = false,
125
+ esbuild: esbuildOpts = {},
126
+ }) {
36
127
  await rm(outdir, { recursive: true, force: true });
37
128
  await mkdir(outdir, { recursive: true });
38
129
 
39
130
  const entryPoints = [{ in: join(here, 'page-entry.ts'), out: 'bundle' }];
40
131
  if (worker) entryPoints.push({ in: worker, out: 'worker' });
41
132
 
133
+ // The built-in __CUT_MODULE__ resolver plugin must ALWAYS run, even when the
134
+ // consumer supplies their own plugins (concat, never clobber).
135
+ const cutResolver = {
136
+ name: 'resolve-cut',
137
+ setup(b) {
138
+ b.onResolve({ filter: /^__CUT_MODULE__$/ }, () => ({ path: cut }));
139
+ },
140
+ };
141
+
142
+ // Opt-in Node-builtin polyfill preset (default off). Produces alias/inject/
143
+ // define fragments that are merged UNDER the consumer's explicit esbuild opts
144
+ // (so the consumer can still override any specific key).
145
+ const preset = nodePolyfills ? await nodePolyfillsPreset(outdir) : null;
146
+
147
+ const {
148
+ plugins: userPlugins = [],
149
+ inject: userInject = [],
150
+ define: userDefine = {},
151
+ alias: userAlias = {},
152
+ loader: userLoader = {},
153
+ external: userExternal = [],
154
+ tsconfig: userTsconfig,
155
+ } = esbuildOpts;
156
+
42
157
  await esbuild.build({
43
158
  entryPoints,
44
159
  outdir,
@@ -47,18 +162,19 @@ export async function buildBundle({ cut, outdir, worker, wasmDirs = [], assets =
47
162
  target: 'es2022',
48
163
  platform: 'browser',
49
164
  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
- ],
165
+ // Merge order: built-ins preset consumer. Consumer wins on key clashes
166
+ // (define/alias/loader) and runs last (plugins). Arrays are concatenated.
167
+ plugins: [cutResolver, ...userPlugins],
168
+ inject: [...(preset?.inject ?? []), ...userInject],
169
+ define: { ...(preset?.define ?? {}), ...userDefine },
170
+ alias: { ...(preset?.alias ?? {}), ...userAlias },
171
+ external: [...userExternal],
172
+ ...(userTsconfig ? { tsconfig: userTsconfig } : {}),
59
173
  // sqlite-wasm ships an mjs that references the wasm by URL; keep it external
60
174
  // to the worker if needed — here we let esbuild bundle JS and copy wasm.
61
- loader: { '.wasm': 'copy' },
175
+ // Consumer loaders merge OVER this (e.g. add `.svg`), but `.wasm` stays copy
176
+ // unless the consumer deliberately overrides that exact key.
177
+ loader: { '.wasm': 'copy', ...userLoader },
62
178
  });
63
179
 
64
180
  // 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,50 @@ 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
+ * Opt-in Node-builtin polyfills for browser bundles of web3 / crypto code
73
+ * (ethereumjs, tevm, anything pulling `readable-stream` / `safe-buffer`).
74
+ * Default **off** — existing consumers are unaffected.
75
+ *
76
+ * When enabled it aliases `buffer` / `node:buffer` to the `buffer` npm
77
+ * package, injects a `Buffer` global, shims a minimal `process`
78
+ * (`{ env: {}, browser: true, version: '', nextTick }`), and sets
79
+ * `define: { global: 'globalThis' }`. Requires the optional `buffer`
80
+ * dependency to be installed. `true` and `'web3'` are equivalent today.
81
+ */
82
+ nodePolyfills?: boolean | 'web3';
83
+ /**
84
+ * esbuild pass-through merged into the harness's internal browser build.
85
+ * Consumer values take precedence; arrays are concatenated AFTER the
86
+ * built-ins, so the `__CUT_MODULE__` resolver and the `.wasm` copy loader
87
+ * always remain. Combine with `nodePolyfills` for web3 code that still needs
88
+ * an extra alias/define.
89
+ */
90
+ esbuild?: {
91
+ plugins?: import('esbuild').Plugin[];
92
+ inject?: string[];
93
+ define?: Record<string, string>;
94
+ alias?: Record<string, string>;
95
+ /** Merged OVER the built-in `{'.wasm':'copy'}` loader. */
96
+ loader?: Record<string, import('esbuild').Loader>;
97
+ external?: string[];
98
+ /** Path to a tsconfig esbuild should honour while bundling. */
99
+ tsconfig?: string;
100
+ };
101
+ /**
102
+ * Serve a FULLY-prebuilt directory as-is (no esbuild, no glue injection). The
103
+ * directory must already contain an `index.html` that boots the harness and
104
+ * sets `window.__harness` (e.g. produced by a consumer's own bundler that
105
+ * imported `playwright-browser-harness/page-entry`).
106
+ *
107
+ * - `serverUrl` omitted → the harness starts its own COOP/COEP server for
108
+ * `outdir` (honouring `coi`/`headers`/`mime`).
109
+ * - `serverUrl` provided → the harness reuses that already-running server and
110
+ * does not start or stop one.
111
+ */
45
112
  prebuilt?: {
46
113
  outdir: string;
47
- serverUrl: string;
114
+ serverUrl?: string;
48
115
  };
49
116
  }
50
117
  export interface MountedHarness {
@@ -58,3 +125,4 @@ export interface MountedHarness {
58
125
  }
59
126
  export declare function mountHarness(page: Page, opts: MountOptions): Promise<MountedHarness>;
60
127
  export { here as harnessDir };
128
+ 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 ?? false,
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.2.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",