playwright-browser-harness 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ronan Sandford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # playwright-browser-harness
2
+
3
+ Run **bundled TypeScript inside a real browser** under Playwright — drive it,
4
+ assert results, measure performance — with the things browser storage needs:
5
+ COOP/COEP cross-origin isolation (`SharedArrayBuffer` / OPFS), Web Workers,
6
+ persistence across reloads, a reset between tests.
7
+
8
+ **Hybrid distribution.** The machinery (`mountHarness`/driver, the COOP/COEP
9
+ server, the esbuild build, the page glue, the contract) ships in this package and
10
+ is **imported, never copied**. A tiny `init` CLI scaffolds only the per-project
11
+ files you edit anyway (a starter `cut.ts`, a spec, a `playwright.config.ts`).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add -D playwright-browser-harness @playwright/test esbuild typescript @types/node
17
+ pnpm exec playwright install chromium
18
+ ```
19
+
20
+ `@playwright/test` and `esbuild` are **peerDependencies** — the consumer drives
21
+ their versions. `@sqlite.org/sqlite-wasm` is *not* a dependency; it is only a
22
+ fixture/consumer dep when you test wasm-SQLite.
23
+
24
+ ## tsconfig (required)
25
+
26
+ ```jsonc
27
+ {
28
+ "compilerOptions": {
29
+ "moduleResolution": "bundler", // resolve 'playwright-browser-harness/contract'
30
+ "allowImportingTsExtensions": true, // the contract ships as raw .ts and is bundled
31
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"]
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Scaffold a starter (optional)
37
+
38
+ ```bash
39
+ npx playwright-browser-harness init tests # writes tests/cut.ts, tests/example.spec.ts, playwright.config.ts
40
+ pnpm exec playwright test
41
+ npx playwright-browser-harness update tests # report drift of scaffolded files vs current templates
42
+ ```
43
+
44
+ ## Use
45
+
46
+ ```ts
47
+ // tests/cut.ts — your code-under-test, BUNDLED INTO THE BROWSER
48
+ import type {CodeUnderTest} from 'playwright-browser-harness/contract';
49
+ import {captureEnv, timed} from 'playwright-browser-harness/contract';
50
+ const cut: CodeUnderTest = { name: 'mine', async run(ctx){ /* ... */ return {results:{},timings:[],errors:[],env:captureEnv()}; } };
51
+ export default cut;
52
+ ```
53
+
54
+ ```ts
55
+ // tests/x.spec.ts — Node side
56
+ import {test, expect} from '@playwright/test';
57
+ import {mountHarness} from 'playwright-browser-harness';
58
+
59
+ test('persists', async ({page}) => {
60
+ const h = await mountHarness(page, {cut: resolve(__dirname,'cut.ts'), coi: false});
61
+ const w = await h.run({phase:'write', params:{n:100}});
62
+ await h.reload();
63
+ const r = await h.run({phase:'read', params:{n:100}});
64
+ expect(r.results.survived).toBe(true);
65
+ await h.dispose();
66
+ });
67
+ ```
68
+
69
+ `MountOptions`: `cut` (required), `coi` (default `true`), `worker`, `wasmDirs`,
70
+ `assets`, `prebuilt`. See the
71
+ [`playwright-browser-test-harness` research topic](../README.md) for the full
72
+ architecture, gotchas, and per-browser OPFS matrix.
package/bin/cli.mjs ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * playwright-browser-harness CLI — the "init/copy-in" half of the HYBRID distribution.
4
+ *
5
+ * The MACHINERY (mountHarness/driver, the COOP/COEP server, the esbuild build,
6
+ * page-entry, contract) ships INSIDE this package and is imported, never copied:
7
+ * import { mountHarness } from 'playwright-browser-harness';
8
+ * import type { CodeUnderTest } from 'playwright-browser-harness/contract';
9
+ *
10
+ * This CLI only scaffolds the genuinely PER-PROJECT files a consumer always edits
11
+ * anyway — a starter `cut.ts`, a starter spec, and a `playwright.config.ts` — so a
12
+ * consuming repo does not start from a blank page. It does NOT copy page-entry/
13
+ * server/build/driver (those stay in node_modules and update via your package
14
+ * manager, no drift).
15
+ *
16
+ * Commands:
17
+ * init [dir] scaffold cut.ts + <name>.spec.ts + playwright.config.ts into <dir>
18
+ * (default: ./tests). Prints the tsconfig + devDeps you need.
19
+ * update [dir] re-show the current templates vs your files (diff). With
20
+ * --force, overwrite the scaffolded files (your edits are lost —
21
+ * commit first). Without it, only reports drift.
22
+ * help this help.
23
+ *
24
+ * Flags: --force (overwrite on init/update), --name <n> (spec base name).
25
+ */
26
+ import {fileURLToPath} from 'node:url';
27
+ import {dirname, join, resolve, relative} from 'node:path';
28
+ import {mkdir, readFile, writeFile, stat} from 'node:fs/promises';
29
+ import {existsSync} from 'node:fs';
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ const templatesDir = resolve(here, '..', 'templates');
33
+ const PKG = 'playwright-browser-harness';
34
+
35
+ function parseArgs(argv) {
36
+ const args = {_: [], force: false, name: 'example'};
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (a === '--force') args.force = true;
40
+ else if (a === '--name') args.name = argv[++i];
41
+ else args._.push(a);
42
+ }
43
+ return args;
44
+ }
45
+
46
+ async function readTemplate(file) {
47
+ return readFile(join(templatesDir, file), 'utf8');
48
+ }
49
+
50
+ // The files the CLI manages. `out` is the destination filename inside <dir>.
51
+ function plan(targetDir, name) {
52
+ return [
53
+ {tmpl: 'cut.ts.tmpl', out: join(targetDir, 'cut.ts')},
54
+ {tmpl: 'example.spec.ts.tmpl', out: join(targetDir, `${name}.spec.ts`)},
55
+ {tmpl: 'playwright.config.ts.tmpl', out: resolve(targetDir, '..', 'playwright.config.ts')},
56
+ ];
57
+ }
58
+
59
+ function tsconfigSnippet() {
60
+ return `{
61
+ "compilerOptions": {
62
+ "target": "ES2022",
63
+ "module": "ESNext",
64
+ "moduleResolution": "bundler", // required: lets cut.ts import '${PKG}/contract'
65
+ "allowImportingTsExtensions": true, // required: the contract is shipped as raw .ts and bundled
66
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
67
+ "strict": true,
68
+ "noEmit": true,
69
+ "skipLibCheck": true,
70
+ "types": ["node"]
71
+ },
72
+ "include": ["src", "tests"]
73
+ }`;
74
+ }
75
+
76
+ function postInstallNote(dir) {
77
+ const rel = relative(process.cwd(), dir) || '.';
78
+ return `
79
+ Scaffolded into ${rel}/ (and ../playwright.config.ts).
80
+
81
+ NEXT STEPS
82
+ 1. Install peers (the harness drives these; they are YOUR devDeps):
83
+ pnpm add -D ${PKG} @playwright/test esbuild typescript @types/node
84
+ pnpm exec playwright install chromium
85
+
86
+ 2. Your tsconfig.json MUST use bundler resolution + .ts imports:
87
+ ${tsconfigSnippet().split('\n').map((l) => ' ' + l).join('\n')}
88
+
89
+ 3. Edit ${rel}/cut.ts (your code-under-test) and run:
90
+ pnpm exec playwright test
91
+
92
+ IMPORTS (already wired in the scaffolded files)
93
+ import { mountHarness } from '${PKG}';
94
+ import type { CodeUnderTest } from '${PKG}/contract';
95
+ import { captureEnv, timed } from '${PKG}/contract';
96
+
97
+ COOP/COEP NOTE
98
+ mountHarness(page, { cut, coi: true }) serves with cross-origin isolation
99
+ (crossOriginIsolated === true → SharedArrayBuffer + OPFS sync VFS). Use
100
+ coi: false for plain IndexedDB / no-isolation paths. The server is owned by the
101
+ package; you toggle it via the option, you do not edit server source.
102
+ `;
103
+ }
104
+
105
+ async function cmdInit(args) {
106
+ const targetDir = resolve(args._[0] ?? 'tests');
107
+ await mkdir(targetDir, {recursive: true});
108
+ const items = plan(targetDir, args.name);
109
+
110
+ let wrote = 0;
111
+ let skipped = 0;
112
+ for (const {tmpl, out} of items) {
113
+ const content = await readTemplate(tmpl);
114
+ if (existsSync(out) && !args.force) {
115
+ console.error(` skip ${relative(process.cwd(), out)} (exists; --force to overwrite)`);
116
+ skipped++;
117
+ continue;
118
+ }
119
+ await mkdir(dirname(out), {recursive: true});
120
+ await writeFile(out, content);
121
+ console.error(` ${args.force && existsSync(out) ? 'write ' : 'create'} ${relative(process.cwd(), out)}`);
122
+ wrote++;
123
+ }
124
+ console.error(`\n${wrote} written, ${skipped} skipped.`);
125
+ console.error(postInstallNote(targetDir));
126
+ }
127
+
128
+ async function cmdUpdate(args) {
129
+ const targetDir = resolve(args._[0] ?? 'tests');
130
+ const items = plan(targetDir, args.name);
131
+ let drift = 0;
132
+
133
+ for (const {tmpl, out} of items) {
134
+ const template = await readTemplate(tmpl);
135
+ const rel = relative(process.cwd(), out);
136
+ if (!existsSync(out)) {
137
+ console.error(` missing ${rel} (run init to create)`);
138
+ drift++;
139
+ continue;
140
+ }
141
+ const current = await readFile(out, 'utf8');
142
+ if (current === template) {
143
+ console.error(` same ${rel}`);
144
+ continue;
145
+ }
146
+ drift++;
147
+ if (args.force) {
148
+ await writeFile(out, template);
149
+ console.error(` reset ${rel} (overwritten with current template)`);
150
+ } else {
151
+ console.error(` drift ${rel} (differs from template; --force to overwrite, commit first)`);
152
+ }
153
+ }
154
+ if (drift === 0) console.error('\nNo drift — scaffolded files match the current templates.');
155
+ else if (!args.force)
156
+ console.error(
157
+ `\n${drift} file(s) differ. These files are YOURS — drift is expected once you edit them. ` +
158
+ `Only the IMPORTED machinery (driver/server/build/page-entry/contract) updates with your package manager.`,
159
+ );
160
+ }
161
+
162
+ function cmdHelp() {
163
+ console.error(`playwright-browser-harness — hybrid distribution CLI
164
+
165
+ USAGE
166
+ npx ${PKG} init [dir] scaffold cut.ts + spec + playwright.config.ts (default dir: ./tests)
167
+ npx ${PKG} update [dir] report drift of scaffolded files vs templates (--force overwrites)
168
+ npx ${PKG} help
169
+
170
+ FLAGS
171
+ --force overwrite existing files (init) / reset to template (update)
172
+ --name <n> spec base name (default: example → example.spec.ts)
173
+
174
+ The harness MACHINERY is imported, not copied:
175
+ import { mountHarness } from '${PKG}';
176
+ import type { CodeUnderTest } from '${PKG}/contract';
177
+ This CLI only scaffolds the per-project files you edit anyway.`);
178
+ }
179
+
180
+ const args = parseArgs(process.argv.slice(2));
181
+ const cmd = args._.shift();
182
+
183
+ try {
184
+ if (cmd === 'init') await cmdInit(args);
185
+ else if (cmd === 'update') await cmdUpdate(args);
186
+ else if (cmd === 'help' || cmd === '--help' || cmd === '-h' || !cmd) cmdHelp();
187
+ else {
188
+ console.error(`unknown command: ${cmd}\n`);
189
+ cmdHelp();
190
+ process.exit(1);
191
+ }
192
+ } catch (e) {
193
+ console.error(String(e instanceof Error ? e.stack ?? e.message : e));
194
+ process.exit(1);
195
+ }
package/dist/build.mjs ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * build.mjs — bundle a code-under-test for the browser with esbuild.
3
+ *
4
+ * Why esbuild over Vite for a *test harness*: we want a single, fast,
5
+ * scriptable function (`buildBundle`) we can call from a Playwright global
6
+ * setup — no dev server, no HMR, no plugin ecosystem needed. esbuild bundles a
7
+ * TS entry (incl. a Web Worker entry) to a flat dir in tens of ms and is a
8
+ * single dependency. Vite shines for app dev (HMR, SSR, rich plugins); here
9
+ * those are pure overhead. See README "esbuild vs Vite".
10
+ *
11
+ * Produces, into <outdir>/:
12
+ * index.html — loads bundle.js
13
+ * bundle.js — page-entry + the chosen code-under-test
14
+ * worker.js — (optional) a Worker entry, if the CUT ships one
15
+ * *.wasm — copied wasm assets (sqlite, etc.)
16
+ */
17
+ import esbuild from 'esbuild';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { mkdir, writeFile, rm, copyFile, readdir } from 'node:fs/promises';
21
+ import { existsSync } from 'node:fs';
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /**
26
+ * @param {object} opts
27
+ * @param {string} opts.cut absolute path to the code-under-test module
28
+ * @param {string} opts.outdir output directory
29
+ * @param {string} [opts.worker] absolute path to an optional Worker entry
30
+ * @param {string[]} [opts.wasmDirs] dirs whose *.wasm files to copy to outdir
31
+ * @param {string[]} [opts.assets] explicit absolute file paths to copy verbatim
32
+ * into outdir (e.g. sqlite3-opfs-async-proxy.js, which the OPFS VFS spawns by
33
+ * URL relative to the worker bundle and esbuild does NOT trace).
34
+ */
35
+ export async function buildBundle({ cut, outdir, worker, wasmDirs = [], assets = [] }) {
36
+ await rm(outdir, { recursive: true, force: true });
37
+ await mkdir(outdir, { recursive: true });
38
+
39
+ const entryPoints = [{ in: join(here, 'page-entry.ts'), out: 'bundle' }];
40
+ if (worker) entryPoints.push({ in: worker, out: 'worker' });
41
+
42
+ await esbuild.build({
43
+ entryPoints,
44
+ outdir,
45
+ bundle: true,
46
+ format: 'esm',
47
+ target: 'es2022',
48
+ platform: 'browser',
49
+ 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
+ ],
59
+ // sqlite-wasm ships an mjs that references the wasm by URL; keep it external
60
+ // to the worker if needed — here we let esbuild bundle JS and copy wasm.
61
+ loader: { '.wasm': 'copy' },
62
+ });
63
+
64
+ // Copy wasm assets (e.g. @sqlite.org/sqlite-wasm's sqlite3.wasm) next to JS.
65
+ for (const dir of wasmDirs) {
66
+ if (!existsSync(dir)) continue;
67
+ for (const f of await readdir(dir)) {
68
+ if (f.endsWith('.wasm')) await copyFile(join(dir, f), join(outdir, f));
69
+ }
70
+ }
71
+ // Copy explicit assets verbatim, preserving basename. GOTCHA: the sqlite-wasm
72
+ // OPFS VFS loads `sqlite3-opfs-async-proxy.js` via `new URL(..., import.meta
73
+ // .url)` from a *separate* Worker it spawns itself; esbuild never sees that
74
+ // string, so the file must be placed next to the worker bundle by hand.
75
+ for (const a of assets) {
76
+ if (!existsSync(a)) continue;
77
+ await copyFile(a, join(outdir, a.split('/').pop()));
78
+ }
79
+
80
+ // Minimal HTML shell. type="module" so top-level await / ESM works.
81
+ const html = `<!doctype html>
82
+ <html>
83
+ <head><meta charset="utf-8"><title>harness</title></head>
84
+ <body>
85
+ <script type="module" src="./bundle.js"></script>
86
+ </body>
87
+ </html>`;
88
+ await writeFile(join(outdir, 'index.html'), html);
89
+
90
+ return { outdir, hasWorker: Boolean(worker) };
91
+ }
92
+
93
+ // CLI: `node harness/build.mjs <cut> <outdir> [worker]`
94
+ if (import.meta.url === `file://${process.argv[1]}`) {
95
+ const [, , cut, outdir, worker] = process.argv;
96
+ if (!cut || !outdir) {
97
+ console.error('usage: build.mjs <cut-module> <outdir> [worker-entry]');
98
+ process.exit(1);
99
+ }
100
+ await buildBundle({
101
+ cut: resolve(cut),
102
+ outdir: resolve(outdir),
103
+ worker: worker ? resolve(worker) : undefined,
104
+ assets: [],
105
+ });
106
+ console.log('built', outdir);
107
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * contract.ts — the stable contract between the harness and a "code-under-test".
3
+ *
4
+ * A consuming topic (e.g. browser-embedded-indexer) supplies a TS module that
5
+ * default-exports a `CodeUnderTest`. The harness bundles it, loads it in a real
6
+ * browser page (and/or a Worker), invokes `run()`, and ships the structured
7
+ * `{ results, timings, errors }` back to the Node test process for assertions.
8
+ *
9
+ * This file is intentionally dependency-free so it can be imported from both the
10
+ * browser bundle and the Node side without pulling in anything browser- or
11
+ * node-specific.
12
+ */
13
+ /** A single timing sample, in milliseconds (from `performance.now()`). */
14
+ export interface Timing {
15
+ label: string;
16
+ ms: number;
17
+ }
18
+ /** Structured result returned from the browser back to Node. */
19
+ export interface RunResult {
20
+ /** Arbitrary JSON-serialisable payload the consumer wants to assert on. */
21
+ results: Record<string, unknown>;
22
+ /** Named perf samples in ms. */
23
+ timings: Timing[];
24
+ /** Any errors collected (stringified). Empty array == success. */
25
+ errors: string[];
26
+ /** Environment facts the harness/consumer may want to assert on. */
27
+ env: EnvInfo;
28
+ }
29
+ /** Environment facts captured in-page so Node can assert on them. */
30
+ export interface EnvInfo {
31
+ crossOriginIsolated: boolean;
32
+ hasSharedArrayBuffer: boolean;
33
+ hasOPFS: boolean;
34
+ userAgent: string;
35
+ }
36
+ /**
37
+ * The pluggable unit. A consuming topic implements this and points the harness
38
+ * at the module (see fixtures/ for two worked examples).
39
+ *
40
+ * `phase` lets the same module participate in a navigate→reload flow: the
41
+ * harness calls `run({ phase: 'write' })`, reloads the page, then calls
42
+ * `run({ phase: 'read' })` to assert persistence survived the reload.
43
+ */
44
+ export interface CodeUnderTest {
45
+ /** Optional human label, surfaced in logs. */
46
+ name?: string;
47
+ run(ctx: RunContext): Promise<RunResult>;
48
+ /** Optional cleanup hook to reset OPFS / IndexedDB between tests. */
49
+ reset?(): Promise<void>;
50
+ }
51
+ export interface RunContext {
52
+ /** 'write' (first load) | 'read' (after reload) | 'once' (single shot). */
53
+ phase: 'write' | 'read' | 'once';
54
+ /** Free-form params the Node side can pass through (e.g. dataset size). */
55
+ params: Record<string, unknown>;
56
+ }
57
+ /** Capture standard environment facts. Safe to call in window or Worker. */
58
+ export declare function captureEnv(): EnvInfo;
59
+ /** Small helper: time an async fn and push a labelled sample. */
60
+ export declare function timed<T>(label: string, out: Timing[], fn: () => Promise<T> | T): Promise<T>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * contract.ts — the stable contract between the harness and a "code-under-test".
3
+ *
4
+ * A consuming topic (e.g. browser-embedded-indexer) supplies a TS module that
5
+ * default-exports a `CodeUnderTest`. The harness bundles it, loads it in a real
6
+ * browser page (and/or a Worker), invokes `run()`, and ships the structured
7
+ * `{ results, timings, errors }` back to the Node test process for assertions.
8
+ *
9
+ * This file is intentionally dependency-free so it can be imported from both the
10
+ * browser bundle and the Node side without pulling in anything browser- or
11
+ * node-specific.
12
+ */
13
+ /** Capture standard environment facts. Safe to call in window or Worker. */
14
+ export function captureEnv() {
15
+ const g = globalThis;
16
+ return {
17
+ crossOriginIsolated: g.crossOriginIsolated === true,
18
+ hasSharedArrayBuffer: typeof g.SharedArrayBuffer !== 'undefined',
19
+ hasOPFS: typeof g.navigator?.storage?.getDirectory === 'function',
20
+ userAgent: g.navigator?.userAgent ?? 'unknown',
21
+ };
22
+ }
23
+ /** Small helper: time an async fn and push a labelled sample. */
24
+ export async function timed(label, out, fn) {
25
+ const t0 = performance.now();
26
+ const v = await fn();
27
+ out.push({ label, ms: performance.now() - t0 });
28
+ return v;
29
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * contract.ts — the stable contract between the harness and a "code-under-test".
3
+ *
4
+ * A consuming topic (e.g. browser-embedded-indexer) supplies a TS module that
5
+ * default-exports a `CodeUnderTest`. The harness bundles it, loads it in a real
6
+ * browser page (and/or a Worker), invokes `run()`, and ships the structured
7
+ * `{ results, timings, errors }` back to the Node test process for assertions.
8
+ *
9
+ * This file is intentionally dependency-free so it can be imported from both the
10
+ * browser bundle and the Node side without pulling in anything browser- or
11
+ * node-specific.
12
+ */
13
+
14
+ /** A single timing sample, in milliseconds (from `performance.now()`). */
15
+ export interface Timing {
16
+ label: string;
17
+ ms: number;
18
+ }
19
+
20
+ /** Structured result returned from the browser back to Node. */
21
+ export interface RunResult {
22
+ /** Arbitrary JSON-serialisable payload the consumer wants to assert on. */
23
+ results: Record<string, unknown>;
24
+ /** Named perf samples in ms. */
25
+ timings: Timing[];
26
+ /** Any errors collected (stringified). Empty array == success. */
27
+ errors: string[];
28
+ /** Environment facts the harness/consumer may want to assert on. */
29
+ env: EnvInfo;
30
+ }
31
+
32
+ /** Environment facts captured in-page so Node can assert on them. */
33
+ export interface EnvInfo {
34
+ crossOriginIsolated: boolean;
35
+ hasSharedArrayBuffer: boolean;
36
+ hasOPFS: boolean;
37
+ userAgent: string;
38
+ }
39
+
40
+ /**
41
+ * The pluggable unit. A consuming topic implements this and points the harness
42
+ * at the module (see fixtures/ for two worked examples).
43
+ *
44
+ * `phase` lets the same module participate in a navigate→reload flow: the
45
+ * harness calls `run({ phase: 'write' })`, reloads the page, then calls
46
+ * `run({ phase: 'read' })` to assert persistence survived the reload.
47
+ */
48
+ export interface CodeUnderTest {
49
+ /** Optional human label, surfaced in logs. */
50
+ name?: string;
51
+ run(ctx: RunContext): Promise<RunResult>;
52
+ /** Optional cleanup hook to reset OPFS / IndexedDB between tests. */
53
+ reset?(): Promise<void>;
54
+ }
55
+
56
+ export interface RunContext {
57
+ /** 'write' (first load) | 'read' (after reload) | 'once' (single shot). */
58
+ phase: 'write' | 'read' | 'once';
59
+ /** Free-form params the Node side can pass through (e.g. dataset size). */
60
+ params: Record<string, unknown>;
61
+ }
62
+
63
+ /** Capture standard environment facts. Safe to call in window or Worker. */
64
+ export function captureEnv(): EnvInfo {
65
+ const g = globalThis as unknown as {
66
+ crossOriginIsolated?: boolean;
67
+ SharedArrayBuffer?: unknown;
68
+ navigator?: { storage?: { getDirectory?: unknown }; userAgent?: string };
69
+ };
70
+ return {
71
+ crossOriginIsolated: g.crossOriginIsolated === true,
72
+ hasSharedArrayBuffer: typeof g.SharedArrayBuffer !== 'undefined',
73
+ hasOPFS:
74
+ typeof g.navigator?.storage?.getDirectory === 'function',
75
+ userAgent: g.navigator?.userAgent ?? 'unknown',
76
+ };
77
+ }
78
+
79
+ /** Small helper: time an async fn and push a labelled sample. */
80
+ export async function timed<T>(
81
+ label: string,
82
+ out: Timing[],
83
+ fn: () => Promise<T> | T,
84
+ ): Promise<T> {
85
+ const t0 = performance.now();
86
+ const v = await fn();
87
+ out.push({ label, ms: performance.now() - t0 });
88
+ return v;
89
+ }
@@ -0,0 +1,60 @@
1
+ /// <reference path="./harness.d.ts" />
2
+ /**
3
+ * driver.ts — the reusable Node-side test driver. THIS is the stable surface a
4
+ * consuming topic (browser-embedded-indexer) imports.
5
+ *
6
+ * Typical use from a consuming topic's Playwright test:
7
+ *
8
+ * import { test, expect } from '@playwright/test';
9
+ * import { mountHarness } from '<this>/harness/driver';
10
+ *
11
+ * test('my store persists', async ({ page }) => {
12
+ * const h = await mountHarness(page, {
13
+ * cut: require.resolve('./my-store.cut.ts'), // your code-under-test
14
+ * coi: true, // serve with COOP/COEP
15
+ * worker: require.resolve('./my-store.worker.ts'), // optional
16
+ * });
17
+ * const w = await h.run({ phase: 'write', params: { n: 1000 } });
18
+ * expect(w.errors).toEqual([]);
19
+ * await h.reload();
20
+ * const r = await h.run({ phase: 'read', params: {} });
21
+ * expect(r.results.survived).toBe(true);
22
+ * console.log(r.timings);
23
+ * await h.dispose();
24
+ * });
25
+ *
26
+ * The driver owns: bundling (esbuild), serving (COOP/COEP toggle), page load,
27
+ * env assertions, and ferrying `{results,timings,errors,env}` across the bridge.
28
+ */
29
+ import type { Page } from '@playwright/test';
30
+ import type { EnvInfo, RunContext, RunResult } from './contract.ts';
31
+ declare const here: string;
32
+ export interface MountOptions {
33
+ /** Absolute path to the code-under-test module (default-exports CodeUnderTest). */
34
+ cut: string;
35
+ /** Serve with COOP/COEP (cross-origin isolation). Default true. */
36
+ coi?: boolean;
37
+ /** Optional absolute path to a Web Worker entry to bundle alongside. */
38
+ worker?: string;
39
+ /** Extra dirs whose *.wasm files should be copied next to the bundle. */
40
+ wasmDirs?: string[];
41
+ /** Explicit asset files to copy verbatim next to the bundle (e.g. the OPFS
42
+ * async proxy js). */
43
+ assets?: string[];
44
+ /** Reuse a prebuilt outdir + server (skip building). */
45
+ prebuilt?: {
46
+ outdir: string;
47
+ serverUrl: string;
48
+ };
49
+ }
50
+ export interface MountedHarness {
51
+ serverUrl: string;
52
+ outdir: string;
53
+ env(): Promise<EnvInfo>;
54
+ run(ctx: RunContext): Promise<RunResult>;
55
+ reset(): Promise<void>;
56
+ reload(): Promise<void>;
57
+ dispose(): Promise<void>;
58
+ }
59
+ export declare function mountHarness(page: Page, opts: MountOptions): Promise<MountedHarness>;
60
+ export { here as harnessDir };
package/dist/driver.js ADDED
@@ -0,0 +1,53 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, join } from 'node:path';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ // build.mjs / server.mjs are JS ESM siblings; imported at runtime by Playwright.
6
+ // @ts-ignore - .mjs has no types
7
+ import { buildBundle } from './build.mjs';
8
+ // @ts-ignore - .mjs has no types
9
+ import { startServer } from './server.mjs';
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ export async function mountHarness(page, opts) {
12
+ let outdir;
13
+ let serverUrl;
14
+ let close;
15
+ if (opts.prebuilt) {
16
+ outdir = opts.prebuilt.outdir;
17
+ serverUrl = opts.prebuilt.serverUrl;
18
+ }
19
+ else {
20
+ outdir = await mkdtemp(join(tmpdir(), 'harness-'));
21
+ await buildBundle({
22
+ cut: opts.cut,
23
+ outdir,
24
+ worker: opts.worker,
25
+ wasmDirs: opts.wasmDirs ?? [],
26
+ assets: opts.assets ?? [],
27
+ });
28
+ const srv = await startServer({ root: outdir, coi: opts.coi ?? true });
29
+ serverUrl = srv.url;
30
+ close = srv.close;
31
+ }
32
+ await loadAndWait(page, serverUrl);
33
+ return {
34
+ serverUrl,
35
+ outdir,
36
+ env: () => page.evaluate(() => window.__harness.env()),
37
+ run: (ctx) => page.evaluate((c) => window.__harness.run(c), ctx),
38
+ reset: () => page.evaluate(() => window.__harness.reset()),
39
+ async reload() {
40
+ await page.reload({ waitUntil: 'load' });
41
+ await page.waitForFunction(() => window.__harness?.ready === true);
42
+ },
43
+ async dispose() {
44
+ if (close)
45
+ await close();
46
+ },
47
+ };
48
+ }
49
+ async function loadAndWait(page, url) {
50
+ await page.goto(url, { waitUntil: 'load' });
51
+ await page.waitForFunction(() => window.__harness?.ready === true);
52
+ }
53
+ export { here as harnessDir };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Ambient global for the in-page harness API, used by driver.ts `page.evaluate`
3
+ * callbacks (which are type-checked against the browser global scope).
4
+ */
5
+ import type { EnvInfo, RunContext, RunResult } from './contract.ts';
6
+
7
+ declare global {
8
+ interface Window {
9
+ __harness: {
10
+ env: () => EnvInfo;
11
+ run: (ctx: RunContext) => Promise<RunResult>;
12
+ reset: () => Promise<void>;
13
+ ready: true;
14
+ };
15
+ }
16
+ }
17
+
18
+ export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * page-entry.ts — the in-page glue. esbuild bundles this with a chosen
3
+ * code-under-test module (injected via the `CUT` define at build time) and the
4
+ * harness exposes `window.__harness.run(ctx)` for Playwright to call via
5
+ * `page.evaluate`.
6
+ *
7
+ * The code-under-test module path is supplied at build time so a consuming topic
8
+ * can point the harness at its own module without editing this file.
9
+ */
10
+ import type { CodeUnderTest, RunContext, RunResult } from './contract.ts';
11
+ import { captureEnv } from './contract.ts';
12
+
13
+ // `__CUT_MODULE__` is replaced by esbuild's `define` with the real specifier.
14
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
15
+ // @ts-ignore - resolved at bundle time
16
+ import cut from '__CUT_MODULE__';
17
+
18
+ declare global {
19
+ interface Window {
20
+ __harness: {
21
+ env: () => ReturnType<typeof captureEnv>;
22
+ run: (ctx: RunContext) => Promise<RunResult>;
23
+ reset: () => Promise<void>;
24
+ ready: true;
25
+ };
26
+ }
27
+ }
28
+
29
+ const mod = cut as CodeUnderTest;
30
+
31
+ window.__harness = {
32
+ env: () => captureEnv(),
33
+ async run(ctx: RunContext): Promise<RunResult> {
34
+ try {
35
+ return await mod.run(ctx);
36
+ } catch (e) {
37
+ return {
38
+ results: {},
39
+ timings: [],
40
+ errors: [String(e instanceof Error ? e.stack ?? e.message : e)],
41
+ env: captureEnv(),
42
+ };
43
+ }
44
+ },
45
+ async reset(): Promise<void> {
46
+ await mod.reset?.();
47
+ },
48
+ ready: true,
49
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * server.mjs — a tiny static file server with a COOP/COEP toggle.
3
+ *
4
+ * Why a real server (not just Playwright `route`): cross-origin-isolation
5
+ * (`crossOriginIsolated === true`, required for SharedArrayBuffer and the OPFS
6
+ * `opfs` sync VFS) keys off *response headers* on the top-level document and its
7
+ * subresources. A real server setting the headers is the most faithful and
8
+ * reusable way; it also serves `.wasm` with the correct MIME, which some VFS
9
+ * loaders require.
10
+ *
11
+ * Toggle COOP/COEP with the `coi` option (default ON). With it OFF you can test
12
+ * the no-isolation paths (`opfs-sahpool`, plain IndexedDB) and measure what
13
+ * breaks when isolation is required.
14
+ */
15
+ import http from 'node:http';
16
+ import { readFile, stat } from 'node:fs/promises';
17
+ import { extname, join, normalize } from 'node:path';
18
+
19
+ const MIME = {
20
+ '.html': 'text/html; charset=utf-8',
21
+ '.js': 'text/javascript; charset=utf-8',
22
+ '.mjs': 'text/javascript; charset=utf-8',
23
+ '.css': 'text/css; charset=utf-8',
24
+ '.json': 'application/json; charset=utf-8',
25
+ // .wasm MUST be served as application/wasm or streaming instantiate / some
26
+ // VFS loaders reject it. This is a classic gotcha.
27
+ '.wasm': 'application/wasm',
28
+ '.map': 'application/json; charset=utf-8',
29
+ };
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {string} opts.root directory to serve
34
+ * @param {boolean} [opts.coi] set COOP/COEP headers (cross-origin isolation)
35
+ * @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
36
+ */
37
+ export async function startServer({ root, coi = true }) {
38
+ const server = http.createServer(async (req, res) => {
39
+ if (coi) {
40
+ // These two headers are what make `crossOriginIsolated === true`.
41
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
42
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
43
+ // CORP so same-origin subresources load under COEP: require-corp.
44
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
45
+ }
46
+ try {
47
+ const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
48
+ let rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
49
+ if (rel === '/' || rel === '\\' || rel === '') rel = '/index.html';
50
+ const file = join(root, rel);
51
+ const s = await stat(file).catch(() => null);
52
+ if (!s || !s.isFile()) {
53
+ res.statusCode = 404;
54
+ res.end('not found: ' + rel);
55
+ return;
56
+ }
57
+ const body = await readFile(file);
58
+ res.setHeader('Content-Type', MIME[extname(file)] ?? 'application/octet-stream');
59
+ res.statusCode = 200;
60
+ res.end(body);
61
+ } catch (e) {
62
+ res.statusCode = 500;
63
+ res.end(String(e));
64
+ }
65
+ });
66
+
67
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
68
+ const addr = server.address();
69
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
70
+ return {
71
+ url: `http://127.0.0.1:${port}`,
72
+ port,
73
+ close: () => new Promise((r) => server.close(() => r(undefined))),
74
+ };
75
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "playwright-browser-harness",
3
+ "version": "0.0.1",
4
+ "type": "module",
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
+ "license": "MIT",
7
+ "author": "Ronan Sandford (https://github.com/wighawag)",
8
+ "homepage": "https://github.com/wighawag/playwright-browser-harness#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/wighawag/playwright-browser-harness.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/wighawag/playwright-browser-harness/issues"
15
+ },
16
+ "bin": {
17
+ "playwright-browser-harness": "./bin/cli.mjs"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/driver.d.ts",
22
+ "default": "./dist/driver.js"
23
+ },
24
+ "./contract": {
25
+ "types": "./dist/contract.ts",
26
+ "default": "./dist/contract.ts"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "scripts": {
31
+ "build": "node build.mjs",
32
+ "prepack": "node build.mjs"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "bin",
37
+ "templates",
38
+ "README.md"
39
+ ],
40
+ "peerDependencies": {
41
+ "@playwright/test": ">=1.40.0",
42
+ "esbuild": ">=0.20.0"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^6.0.3",
46
+ "@types/node": "^25.9.1",
47
+ "@playwright/test": "^1.60.0",
48
+ "esbuild": "^0.28.0"
49
+ },
50
+ "keywords": [
51
+ "playwright",
52
+ "browser",
53
+ "test-harness",
54
+ "esbuild",
55
+ "opfs",
56
+ "coop-coep",
57
+ "indexeddb",
58
+ "sqlite-wasm"
59
+ ],
60
+ "pnpm": {
61
+ "onlyBuiltDependencies": [
62
+ "esbuild"
63
+ ]
64
+ }
65
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * cut.ts — YOUR code-under-test. This file is YOURS to edit (scaffolded by
3
+ * `playwright-browser-harness init`). It is BUNDLED INTO THE BROWSER by the harness's
4
+ * esbuild step and loaded behind `window.__harness`.
5
+ *
6
+ * Implement the `CodeUnderTest` contract: drive your store/logic, return a
7
+ * structured { results, timings, errors, env } the Node test asserts on.
8
+ *
9
+ * The contract is imported from the package (raw .ts, bundled by esbuild under
10
+ * `moduleResolution: bundler` + `allowImportingTsExtensions`).
11
+ */
12
+ import type {CodeUnderTest, RunContext, RunResult} from 'playwright-browser-harness/contract';
13
+ import {captureEnv, timed} from 'playwright-browser-harness/contract';
14
+
15
+ const DB = 'my-store';
16
+ const STORE = 'kv';
17
+
18
+ function open(): Promise<IDBDatabase> {
19
+ return new Promise((resolve, reject) => {
20
+ const req = indexedDB.open(DB, 1);
21
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
22
+ req.onsuccess = () => resolve(req.result);
23
+ req.onerror = () => reject(req.error);
24
+ });
25
+ }
26
+
27
+ function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
28
+ return new Promise((resolve, reject) => {
29
+ const t = db.transaction(STORE, mode);
30
+ const r = fn(t.objectStore(STORE));
31
+ r.onsuccess = () => resolve(r.result);
32
+ r.onerror = () => reject(r.error);
33
+ });
34
+ }
35
+
36
+ const cut: CodeUnderTest = {
37
+ name: 'my-store',
38
+ async run(ctx: RunContext): Promise<RunResult> {
39
+ const timings: RunResult['timings'] = [];
40
+ const errors: string[] = [];
41
+ const results: Record<string, unknown> = {};
42
+ const N = Number(ctx.params.n ?? 100);
43
+
44
+ const db = await timed('open', timings, open);
45
+
46
+ if (ctx.phase === 'write' || ctx.phase === 'once') {
47
+ await timed('write', timings, async () => {
48
+ for (let i = 0; i < N; i++) await tx(db, 'readwrite', (s) => s.put({i, v: `val-${i}`}, i));
49
+ await tx(db, 'readwrite', (s) => s.put({count: N}, '__meta__'));
50
+ });
51
+ results.wrote = N;
52
+ }
53
+
54
+ if (ctx.phase === 'read' || ctx.phase === 'once') {
55
+ const meta = await tx<{count: number} | undefined>(db, 'readonly', (s) => s.get('__meta__'));
56
+ results.survived = Boolean(meta && meta.count === N);
57
+ results.metaCount = meta?.count ?? 0;
58
+ }
59
+
60
+ db.close();
61
+ return {results, timings, errors, env: captureEnv()};
62
+ },
63
+ async reset(): Promise<void> {
64
+ await new Promise<void>((resolve) => {
65
+ const req = indexedDB.deleteDatabase(DB);
66
+ req.onsuccess = () => resolve();
67
+ req.onerror = () => resolve();
68
+ req.onblocked = () => resolve();
69
+ });
70
+ },
71
+ };
72
+
73
+ export default cut;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * example.spec.ts — a starter Playwright test that mounts YOUR cut.ts in a real
3
+ * browser (scaffolded by `playwright-browser-harness init`). Edit freely.
4
+ *
5
+ * `mountHarness` is imported from the package; it owns bundling (esbuild),
6
+ * serving (COOP/COEP toggle), page load, and ferrying results across the bridge.
7
+ * You only point it at your `cut.ts` (and optional `worker.ts`).
8
+ */
9
+ import {test, expect} from '@playwright/test';
10
+ import {fileURLToPath} from 'node:url';
11
+ import {dirname, resolve} from 'node:path';
12
+ import {mountHarness} from 'playwright-browser-harness';
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const cut = resolve(here, './cut.ts');
16
+
17
+ test('my store persists across reload (no COOP/COEP)', async ({page}) => {
18
+ const h = await mountHarness(page, {cut, coi: false});
19
+
20
+ const env = await h.env();
21
+ expect(env.crossOriginIsolated).toBe(false);
22
+
23
+ await h.reset();
24
+
25
+ const w = await h.run({phase: 'write', params: {n: 100}});
26
+ expect(w.errors).toEqual([]);
27
+ expect(w.results.wrote).toBe(100);
28
+
29
+ await h.reload();
30
+
31
+ const r = await h.run({phase: 'read', params: {n: 100}});
32
+ expect(r.errors).toEqual([]);
33
+ expect(r.results.survived).toBe(true);
34
+ expect(r.results.metaCount).toBe(100);
35
+
36
+ console.log(`[my-store] coi=${env.crossOriginIsolated} timings=${JSON.stringify(r.timings)}`);
37
+
38
+ await h.dispose();
39
+ });
@@ -0,0 +1,25 @@
1
+ import {defineConfig, devices} from '@playwright/test';
2
+
3
+ /**
4
+ * Scaffolded by `playwright-browser-harness init`.
5
+ *
6
+ * The harness brings up its OWN static server per test (COOP/COEP toggle), so
7
+ * there is NO Playwright `webServer` entry. Tests are TS — Playwright bundles its
8
+ * own TS loader.
9
+ *
10
+ * Chromium is the primary target (full OPFS + SharedArrayBuffer). Uncomment
11
+ * firefox/webkit to surface per-browser differences (some OPFS cases skip there;
12
+ * see the harness topic README gotcha table).
13
+ */
14
+ export default defineConfig({
15
+ testDir: './tests',
16
+ fullyParallel: false, // OPFS + shared servers: keep determinism
17
+ workers: 1,
18
+ reporter: [['list']],
19
+ timeout: 60_000,
20
+ projects: [
21
+ {name: 'chromium', use: {...devices['Desktop Chrome']}},
22
+ // {name: 'firefox', use: {...devices['Desktop Firefox']}},
23
+ // {name: 'webkit', use: {...devices['Desktop Safari']}},
24
+ ],
25
+ });