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 +172 -2
- package/dist/build.mjs +127 -11
- package/dist/driver.d.ts +72 -4
- package/dist/driver.js +75 -3
- package/dist/page-bootstrap.js +45 -0
- package/package.json +26 -5
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` (
|
|
70
|
-
|
|
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({
|
|
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
|
-
//
|
|
51
|
-
plugins
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
46
|
-
"@
|
|
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
|
-
"
|
|
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",
|