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 +21 -0
- package/README.md +72 -0
- package/bin/cli.mjs +195 -0
- package/dist/build.mjs +107 -0
- package/dist/contract.d.ts +60 -0
- package/dist/contract.js +29 -0
- package/dist/contract.ts +89 -0
- package/dist/driver.d.ts +60 -0
- package/dist/driver.js +53 -0
- package/dist/harness.d.ts +18 -0
- package/dist/page-entry.ts +49 -0
- package/dist/server.mjs +75 -0
- package/package.json +65 -0
- package/templates/cut.ts.tmpl +73 -0
- package/templates/example.spec.ts.tmpl +39 -0
- package/templates/playwright.config.ts.tmpl +25 -0
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>;
|
package/dist/contract.js
ADDED
|
@@ -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
|
+
}
|
package/dist/contract.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/driver.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/server.mjs
ADDED
|
@@ -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
|
+
});
|