@vmz/test 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,12 +1,49 @@
1
1
  {
2
2
  "name": "@vmz/test",
3
- "version": "0.0.1",
4
- "description": "VMZ placeholder 鈥?not for production use.",
5
- "license": "MIT",
6
- "private": false,
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "description": "VMZ native test protocol + Compile/Logic/Browser/SSR/Resume/Deployment hosts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./protocol": {
14
+ "types": "./dist/protocol.d.ts",
15
+ "default": "./dist/protocol.js"
16
+ },
17
+ "./discover": {
18
+ "types": "./dist/discover.d.ts",
19
+ "default": "./dist/discover.js"
20
+ }
21
+ },
7
22
  "files": [
23
+ "dist",
24
+ "src",
8
25
  "README.md"
9
26
  ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json"
29
+ },
30
+ "dependencies": {
31
+ "@vmz/core": "0.0.2",
32
+ "@vmz/protocol": "0.0.2",
33
+ "linkedom": "^0.18.13",
34
+ "puppeteer-core": "^24.11.2"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "keywords": [
40
+ "vmz",
41
+ "test",
42
+ "browser-host"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
10
47
  "repository": {
11
48
  "type": "git",
12
49
  "url": "git+https://github.com/doki-land/vmz-framework.git"
package/src/browser.ts ADDED
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Browser Host for `vmz test --mode browser` (T2 close slice).
3
+ *
4
+ * Real Chromium/Chrome via CDP. Transport may use puppeteer-core as a CDP
5
+ * client — that is NOT the Playwright/Puppeteer *test model*. Manifest actions
6
+ * and assertions remain the VMZ Browser Host protocol; same Direct schedule as
7
+ * production (`__vmzCreate` in a real document).
8
+ *
9
+ * Design: 规划设计/vmz/16 §T2 · §5 浏览器连接
10
+ */
11
+
12
+ import { spawn, type ChildProcess } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import http from 'node:http';
15
+ import net from 'node:net';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { resolveChunkArtifacts } from './compile.js';
19
+
20
+ type Diag = { severity: string; message: string; [k: string]: unknown };
21
+
22
+ export type BrowserResult = {
23
+ status: 'passed' | 'failed' | 'error';
24
+ diagnostics: Diag[];
25
+ planId: string | null;
26
+ programId: string | null;
27
+ };
28
+
29
+ const MIME: Record<string, string> = {
30
+ '.html': 'text/html; charset=utf-8',
31
+ '.js': 'text/javascript; charset=utf-8',
32
+ '.mjs': 'text/javascript; charset=utf-8',
33
+ '.json': 'application/json; charset=utf-8',
34
+ '.css': 'text/css; charset=utf-8',
35
+ '.map': 'application/json; charset=utf-8',
36
+ };
37
+
38
+ function findChromeExecutable(): string | null {
39
+ if (process.env.VMZ_BROWSER && fs.existsSync(process.env.VMZ_BROWSER)) {
40
+ return process.env.VMZ_BROWSER;
41
+ }
42
+ if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
43
+ return process.env.CHROME_PATH;
44
+ }
45
+ const candidates =
46
+ process.platform === 'win32'
47
+ ? [
48
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
49
+ path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
50
+ path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
51
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
52
+ ]
53
+ : process.platform === 'darwin'
54
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium']
55
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
56
+ for (const c of candidates) {
57
+ if (c && fs.existsSync(c)) return c;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function startStaticServer(rootDir: string): Promise<{ port: number; close: () => Promise<void> }> {
63
+ const server = http.createServer((req, res) => {
64
+ try {
65
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
66
+ let rel = decodeURIComponent(url.pathname);
67
+ if (rel === '/' || rel === '/__vmz/harness') {
68
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
69
+ res.end(
70
+ `<!DOCTYPE html><html><head><meta charset="utf-8"><title>vmz browser host</title></head><body><div id="app"></div></body></html>`,
71
+ );
72
+ return;
73
+ }
74
+ if (rel.startsWith('/')) rel = rel.slice(1);
75
+ const filePath = path.normalize(path.join(rootDir, rel));
76
+ if (!filePath.startsWith(path.normalize(rootDir))) {
77
+ res.writeHead(403);
78
+ res.end('forbidden');
79
+ return;
80
+ }
81
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
82
+ res.writeHead(404);
83
+ res.end('not found');
84
+ return;
85
+ }
86
+ const ext = path.extname(filePath).toLowerCase();
87
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
88
+ fs.createReadStream(filePath).pipe(res);
89
+ } catch (e) {
90
+ res.writeHead(500);
91
+ res.end(e instanceof Error ? e.message : String(e));
92
+ }
93
+ });
94
+ return new Promise((resolve, reject) => {
95
+ server.listen(0, '127.0.0.1', () => {
96
+ const addr = server.address();
97
+ if (!addr || typeof addr === 'string') {
98
+ reject(new Error('browser host: failed to bind'));
99
+ return;
100
+ }
101
+ resolve({
102
+ port: addr.port,
103
+ close: () =>
104
+ new Promise((res, rej) => {
105
+ server.close((err) => (err ? rej(err) : res()));
106
+ }),
107
+ });
108
+ });
109
+ server.on('error', reject);
110
+ });
111
+ }
112
+
113
+ async function loadPuppeteerCore(): Promise<{
114
+ launch: (...args: any[]) => Promise<any>;
115
+ connect: (...args: any[]) => Promise<any>;
116
+ }> {
117
+ try {
118
+ const mod: any = await import('puppeteer-core');
119
+ const puppeteer = mod?.default ?? mod;
120
+ if (typeof puppeteer?.launch !== 'function') {
121
+ throw new Error('puppeteer-core.launch missing');
122
+ }
123
+ if (typeof puppeteer?.connect !== 'function') {
124
+ throw new Error('puppeteer-core.connect missing');
125
+ }
126
+ return puppeteer;
127
+ } catch (err) {
128
+ throw new Error(
129
+ `puppeteer-core required for browser mode (CDP transport). Install in @vmz/test or set workspace dep. (${err instanceof Error ? err.message : err})`,
130
+ );
131
+ }
132
+ }
133
+
134
+ function waitLocalPort(port: number, ms = 20_000): Promise<void> {
135
+ const start = Date.now();
136
+ return new Promise((resolve, reject) => {
137
+ const tick = () => {
138
+ const socket = net.connect({ port, host: '127.0.0.1' }, () => {
139
+ socket.end();
140
+ resolve();
141
+ });
142
+ socket.on('error', () => {
143
+ if (Date.now() - start > ms) reject(new Error(`port ${port} not open within ${ms}ms`));
144
+ else setTimeout(tick, 100);
145
+ });
146
+ };
147
+ tick();
148
+ });
149
+ }
150
+
151
+ /** Spawn Chrome with remote debugging and connect — more reliable on GHA than puppeteer.launch. */
152
+ async function connectChromeViaDebugPort(
153
+ puppeteer: { connect: (...args: any[]) => Promise<any> },
154
+ chromePath: string,
155
+ args: string[],
156
+ ): Promise<{ browser: any; child: ChildProcess; profileDir: string }> {
157
+ const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vmz-browser-'));
158
+ const port = 9200 + Math.floor(Math.random() * 700);
159
+ const child = spawn(
160
+ chromePath,
161
+ [...args, `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', 'about:blank'],
162
+ {
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ env: { ...process.env, HOME: profileDir },
165
+ },
166
+ );
167
+ child.stderr?.on('data', () => {});
168
+ child.stdout?.on('data', () => {});
169
+ try {
170
+ await waitLocalPort(port);
171
+ const browser = await puppeteer.connect({
172
+ browserURL: `http://127.0.0.1:${port}`,
173
+ protocolTimeout: 60_000,
174
+ });
175
+ return { browser, child, profileDir };
176
+ } catch (err) {
177
+ try {
178
+ child.kill('SIGKILL');
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+ try {
183
+ fs.rmSync(profileDir, { recursive: true, force: true });
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ throw err;
188
+ }
189
+ }
190
+
191
+ export async function runBrowserManifest(
192
+ manifest: Record<string, unknown>,
193
+ ctx: {
194
+ outDir: string;
195
+ },
196
+ ): Promise<BrowserResult> {
197
+ const diagnostics: Diag[] = [];
198
+ const fail = (message: string, extra: Record<string, unknown> = {}) => {
199
+ diagnostics.push({ severity: 'error', message, ...extra });
200
+ };
201
+
202
+ const program = manifest.program && typeof manifest.program === 'object' ? (manifest.program as Record<string, unknown>) : {};
203
+ const chunkId = String(program.chunkId || '');
204
+ const programId = chunkId || null;
205
+
206
+ if (!chunkId) {
207
+ fail('program.chunkId missing');
208
+ return { status: 'error', diagnostics, planId: null, programId: null };
209
+ }
210
+
211
+ const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
212
+ if (!arts.clientPath) {
213
+ fail(`missing ${chunkId}.client.js`);
214
+ return { status: 'failed', diagnostics, planId: null, programId };
215
+ }
216
+
217
+ const chrome = findChromeExecutable();
218
+ if (!chrome) {
219
+ fail('browser host: Chrome/Edge not found (set VMZ_BROWSER or CHROME_PATH to a Chromium binary)');
220
+ return { status: 'error', diagnostics, planId: null, programId };
221
+ }
222
+
223
+ let server: { port: number; close: () => Promise<void> } | null = null;
224
+ let browser: any = null;
225
+ let profileDir: string | null = null;
226
+ let chromeChild: ChildProcess | null = null;
227
+
228
+ try {
229
+ const puppeteer = await loadPuppeteerCore();
230
+ server = await startStaticServer(ctx.outDir);
231
+ const origin = `http://127.0.0.1:${server.port}`;
232
+
233
+ // CI: spawn+connect first (puppeteer.launch often "Connection closed" on Chrome for Testing).
234
+ const ci = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
235
+ const commonArgs = [
236
+ '--no-sandbox',
237
+ '--disable-setuid-sandbox',
238
+ '--disable-dev-shm-usage',
239
+ '--disable-gpu',
240
+ '--font-render-hinting=none',
241
+ '--mute-audio',
242
+ '--disable-extensions',
243
+ ];
244
+ let lastLaunchErr: unknown;
245
+ if (ci) {
246
+ try {
247
+ if (process.env.VMZ_BROWSER_DEBUG === '1') {
248
+ console.error(`[vmz-test browser] chrome=${chrome} try=spawn+connect`);
249
+ }
250
+ const connected = await connectChromeViaDebugPort(puppeteer, chrome, [...commonArgs, '--headless=new']);
251
+ browser = connected.browser;
252
+ chromeChild = connected.child;
253
+ profileDir = connected.profileDir;
254
+ lastLaunchErr = null;
255
+ } catch (err) {
256
+ lastLaunchErr = err;
257
+ console.error(`[vmz-test browser] spawn+connect failed: ${err instanceof Error ? err.message : err}`);
258
+ }
259
+ }
260
+ if (!browser) {
261
+ const launchAttempts = ci
262
+ ? [
263
+ { label: 'pipe', pipe: true, args: [...commonArgs, '--headless=new'] },
264
+ { label: 'ws', pipe: false, args: [...commonArgs, '--headless=new'] },
265
+ {
266
+ label: 'pipe+single-process',
267
+ pipe: true,
268
+ args: [...commonArgs, '--headless=new', '--single-process', '--disable-software-rasterizer'],
269
+ },
270
+ ]
271
+ : [{ label: 'local', pipe: false, args: commonArgs }];
272
+
273
+ for (const attempt of launchAttempts) {
274
+ profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vmz-browser-'));
275
+ try {
276
+ if (ci || process.env.VMZ_BROWSER_DEBUG === '1') {
277
+ console.error(`[vmz-test browser] chrome=${chrome} try=${attempt.label} args=${attempt.args.join(' ')}`);
278
+ }
279
+ browser = await puppeteer.launch({
280
+ executablePath: chrome,
281
+ headless: true,
282
+ pipe: attempt.pipe,
283
+ protocolTimeout: 60_000,
284
+ dumpio: process.env.VMZ_BROWSER_DEBUG === '1',
285
+ args: [...attempt.args, `--user-data-dir=${profileDir}`, '--no-first-run'],
286
+ });
287
+ await new Promise((r) => setTimeout(r, 200));
288
+ lastLaunchErr = null;
289
+ break;
290
+ } catch (err) {
291
+ lastLaunchErr = err;
292
+ browser = null;
293
+ try {
294
+ fs.rmSync(profileDir, { recursive: true, force: true });
295
+ } catch {
296
+ /* ignore */
297
+ }
298
+ profileDir = null;
299
+ console.error(`[vmz-test browser] launch ${attempt.label} failed: ${err instanceof Error ? err.message : err}`);
300
+ }
301
+ }
302
+ }
303
+ if (!browser) {
304
+ throw lastLaunchErr instanceof Error ? lastLaunchErr : new Error(`browser launch failed: ${String(lastLaunchErr)}`);
305
+ }
306
+ const page = await browser.newPage();
307
+ page.setDefaultTimeout(15000);
308
+ await page.goto(`${origin}/__vmz/harness`, { waitUntil: 'domcontentloaded' });
309
+
310
+ const components = program.components && typeof program.components === 'object' ? (program.components as Record<string, string>) : {};
311
+
312
+ const boot = await page.evaluate(
313
+ async (cfg: { origin: string; chunkPath: string; components: Record<string, string> }) => {
314
+ const dom = await import(/* @vite-ignore */ `${cfg.origin}/vmz-dom.js`);
315
+ const Comp = (await import(/* @vite-ignore */ `${cfg.origin}/${cfg.chunkPath}.client.js`)).default;
316
+ const map: Record<string, unknown> = {};
317
+ for (const [name, chunk] of Object.entries(cfg.components)) {
318
+ map[name] = (await import(/* @vite-ignore */ `${cfg.origin}/${chunk}.client.js`)).default;
319
+ }
320
+ if (Object.keys(map).length && typeof dom.registerComponents === 'function') {
321
+ dom.registerComponents(map);
322
+ }
323
+ const app = document.getElementById('app');
324
+ if (!app) return { ok: false, error: '#app missing' };
325
+ if (!Comp?.__vmzDirect || typeof Comp.__vmzCreate !== 'function') {
326
+ return { ok: false, error: 'Direct __vmzCreate required' };
327
+ }
328
+ (window as any).__vmzBrowser = {
329
+ dom,
330
+ Comp,
331
+ app,
332
+ inst: null as unknown,
333
+ buttonBefore: null as Element | null,
334
+ capturedChild: null as unknown,
335
+ lastPrecision: null as unknown,
336
+ };
337
+ return { ok: true };
338
+ },
339
+ {
340
+ origin,
341
+ chunkPath: chunkId.replace(/\\/g, '/'),
342
+ components,
343
+ },
344
+ );
345
+
346
+ if (!boot?.ok) {
347
+ fail(`browser boot: ${boot?.error || 'unknown'}`);
348
+ return {
349
+ status: 'error',
350
+ diagnostics,
351
+ planId: null,
352
+ programId,
353
+ };
354
+ }
355
+
356
+ const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
357
+ for (const raw of actions) {
358
+ const a = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
359
+ const kind = String(a.kind || '');
360
+ try {
361
+ if (kind === 'mount') {
362
+ const props = a.props && typeof a.props === 'object' ? a.props : {};
363
+ const r = await page.evaluate(async (p: object) => {
364
+ const ctx = (window as any).__vmzBrowser;
365
+ let createHits = 0;
366
+ const orig = ctx.Comp.__vmzCreate;
367
+ ctx.Comp.__vmzCreate = function (this: unknown, api: unknown) {
368
+ createHits += 1;
369
+ return orig.call(this, api);
370
+ };
371
+ ctx.inst = await ctx.dom.mount(ctx.Comp, ctx.app, p);
372
+ ctx.Comp.__vmzCreate = orig;
373
+ ctx.buttonBefore = ctx.app.querySelector('button');
374
+ return { createHits, text: ctx.app.textContent || '' };
375
+ }, props);
376
+ if (r.createHits !== 1) fail(`mount must call __vmzCreate once, got ${r.createHits}`);
377
+ continue;
378
+ }
379
+ if (kind === 'click') {
380
+ const selector = typeof a.selector === 'string' ? a.selector : 'button';
381
+ // Real browser input path: Element.click in page (not linkedom).
382
+ const ok = await page.evaluate((sel: string) => {
383
+ const ctx = (window as any).__vmzBrowser;
384
+ const el = ctx.app.querySelector(sel) as HTMLElement | null;
385
+ if (!el) return false;
386
+ el.click();
387
+ return true;
388
+ }, selector);
389
+ if (!ok) fail(`click: no element for ${JSON.stringify(selector)}`);
390
+ continue;
391
+ }
392
+ if (kind === 'write') {
393
+ const field = String(a.field || '');
394
+ await page.evaluate(
395
+ (args: { field: string; value: unknown }) => {
396
+ const ctx = (window as any).__vmzBrowser;
397
+ if (!ctx.inst) throw new Error('write before mount');
398
+ ctx.inst[args.field] = args.value;
399
+ },
400
+ { field, value: a.value },
401
+ );
402
+ continue;
403
+ }
404
+ if (kind === 'flush') {
405
+ await page.evaluate(async () => {
406
+ const ctx = (window as any).__vmzBrowser;
407
+ if (!ctx.inst) throw new Error('flush before mount');
408
+ await ctx.dom.flushPending(ctx.inst);
409
+ });
410
+ continue;
411
+ }
412
+ if (kind === 'destroy') {
413
+ await page.evaluate(() => {
414
+ const ctx = (window as any).__vmzBrowser;
415
+ if (!ctx.inst) throw new Error('destroy before mount');
416
+ ctx.dom.destroy(ctx.inst);
417
+ });
418
+ continue;
419
+ }
420
+ if (kind === 'capture_child') {
421
+ const selector = typeof a.selector === 'string' ? a.selector : '';
422
+ const ok = await page.evaluate((sel: string) => {
423
+ const ctx = (window as any).__vmzBrowser;
424
+ const el = ctx.app.querySelector(sel) as any;
425
+ if (!el?.__vmzInst) return false;
426
+ ctx.capturedChild = el.__vmzInst;
427
+ return true;
428
+ }, selector);
429
+ if (!ok) fail(`capture_child: no inst for ${JSON.stringify(selector)}`);
430
+ continue;
431
+ }
432
+ if (kind === 'precision_reset') {
433
+ await page.evaluate(() => {
434
+ const ctx = (window as any).__vmzBrowser;
435
+ if (typeof ctx.dom.__vmzPrecisionEnable === 'function') ctx.dom.__vmzPrecisionEnable(true);
436
+ if (typeof ctx.dom.__vmzPrecisionReset === 'function') ctx.dom.__vmzPrecisionReset();
437
+ });
438
+ continue;
439
+ }
440
+ fail(`unknown browser action ${JSON.stringify(kind)}`);
441
+ } catch (e) {
442
+ fail(`action ${kind}: ${e instanceof Error ? e.message : String(e)}`);
443
+ }
444
+ }
445
+
446
+ const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
447
+ for (const raw of assertions) {
448
+ const a = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
449
+ const kind = String(a.kind || '');
450
+ const expect = a.expect && typeof a.expect === 'object' ? (a.expect as Record<string, unknown>) : {};
451
+
452
+ if (kind === 'text') {
453
+ const text = await page.evaluate(() => {
454
+ const ctx = (window as any).__vmzBrowser;
455
+ return ctx.app.textContent || '';
456
+ });
457
+ if (expect.equals != null && text !== String(expect.equals)) {
458
+ fail(`text equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(text)}`);
459
+ }
460
+ if (expect.contains != null && !text.includes(String(expect.contains))) {
461
+ fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
462
+ }
463
+ continue;
464
+ }
465
+ if (kind === 'nodeIdentity') {
466
+ const sel = typeof expect.selector === 'string' ? expect.selector : 'button';
467
+ const same = await page.evaluate((s: string) => {
468
+ const ctx = (window as any).__vmzBrowser;
469
+ const after = ctx.app.querySelector(s);
470
+ return !!(ctx.buttonBefore && after && after === ctx.buttonBefore);
471
+ }, sel);
472
+ if (!same) fail(`nodeIdentity failed for ${sel} (real browser document)`);
473
+ continue;
474
+ }
475
+ if (kind === 'state') {
476
+ const state = await page.evaluate((keys: string[]) => {
477
+ const ctx = (window as any).__vmzBrowser;
478
+ const out: Record<string, unknown> = {};
479
+ for (const k of keys) out[k] = ctx.inst?.[k];
480
+ return out;
481
+ }, Object.keys(expect));
482
+ for (const [k, v] of Object.entries(expect)) {
483
+ if (state[k] !== v) {
484
+ fail(`state.${k} want ${JSON.stringify(v)}, got ${JSON.stringify(state[k])}`);
485
+ }
486
+ }
487
+ continue;
488
+ }
489
+ if (kind === 'host') {
490
+ if (expect.kind === 'browser' || expect.realDocument === true) {
491
+ const ok = await page.evaluate(() => typeof document !== 'undefined' && !!(document as any).createElement);
492
+ if (!ok) fail('host.realDocument failed');
493
+ }
494
+ continue;
495
+ }
496
+ if (kind === 'destroyed') {
497
+ const want = expect.value !== false;
498
+ const got = await page.evaluate(() => {
499
+ const ctx = (window as any).__vmzBrowser;
500
+ return Boolean(ctx.inst?.__vmzDestroyed);
501
+ });
502
+ if (got !== want) fail(`__vmzDestroyed want ${want}, got ${got}`);
503
+ continue;
504
+ }
505
+ if (kind === 'childDestroyed') {
506
+ const want = expect.value !== false;
507
+ const got = await page.evaluate(() => {
508
+ const ctx = (window as any).__vmzBrowser;
509
+ if (!ctx.capturedChild) return null;
510
+ return Boolean(ctx.capturedChild.__vmzDestroyed);
511
+ });
512
+ if (got == null) fail('childDestroyed: no captured child (use capture_child action)');
513
+ else if (got !== want) fail(`child __vmzDestroyed want ${want}, got ${got}`);
514
+ continue;
515
+ }
516
+ if (kind === 'precision') {
517
+ const snap = await page.evaluate(() => {
518
+ const ctx = (window as any).__vmzBrowser;
519
+ if (typeof ctx.dom.__vmzPrecisionSnapshot !== 'function') return null;
520
+ return ctx.dom.__vmzPrecisionSnapshot();
521
+ });
522
+ if (!snap) {
523
+ fail('precision snapshot unavailable');
524
+ continue;
525
+ }
526
+ if (expect.minWrites != null && Number(snap.writes || 0) < Number(expect.minWrites)) {
527
+ fail(`precision.writes want >= ${expect.minWrites}, got ${snap.writes}`);
528
+ }
529
+ if (expect.maxWrites != null && Number(snap.writes || 0) > Number(expect.maxWrites)) {
530
+ fail(`precision.writes want <= ${expect.maxWrites}, got ${snap.writes}`);
531
+ }
532
+ if (expect.maxBindingEvals != null && Number(snap.bindingEvals || 0) > Number(expect.maxBindingEvals)) {
533
+ fail(`precision.bindingEvals want <= ${expect.maxBindingEvals}, got ${snap.bindingEvals}`);
534
+ }
535
+ if (expect.maxPatchExecs != null && Number(snap.patchExecs || 0) > Number(expect.maxPatchExecs)) {
536
+ fail(`precision.patchExecs want <= ${expect.maxPatchExecs}, got ${snap.patchExecs}`);
537
+ }
538
+ if (expect.patchesIncludeDep != null) {
539
+ const dep = String(expect.patchesIncludeDep);
540
+ const map = (snap.patchesByDep as Record<string, number>) || {};
541
+ if (!map[dep]) fail(`precision.patchesByDep missing ${dep}: ${JSON.stringify(map)}`);
542
+ }
543
+ if (expect.writesIncludeRoot != null) {
544
+ const rootKey = String(expect.writesIncludeRoot);
545
+ const map = (snap.writesByRoot as Record<string, number>) || {};
546
+ if (!map[rootKey]) fail(`precision.writesByRoot missing ${rootKey}: ${JSON.stringify(map)}`);
547
+ }
548
+ if (expect.domCreates === 0 || expect.domCreates === false) {
549
+ if (Number(snap.domCreates || 0) !== 0) {
550
+ fail(`precision.domCreates want 0 after action window, got ${snap.domCreates}`);
551
+ }
552
+ }
553
+ continue;
554
+ }
555
+ if (kind === 'graph' || kind === 'plan' || kind === 'diagnostic' || kind === 'view') {
556
+ continue;
557
+ }
558
+ fail(`unknown browser assertion ${JSON.stringify(kind)}`);
559
+ }
560
+ } catch (e) {
561
+ fail(e instanceof Error ? e.message : String(e));
562
+ } finally {
563
+ try {
564
+ if (browser) {
565
+ if (chromeChild) await browser.disconnect();
566
+ else await browser.close();
567
+ }
568
+ } catch {
569
+ /* ignore */
570
+ }
571
+ try {
572
+ if (chromeChild) chromeChild.kill('SIGKILL');
573
+ } catch {
574
+ /* ignore */
575
+ }
576
+ try {
577
+ if (profileDir) {
578
+ fs.rmSync(profileDir, { recursive: true, force: true });
579
+ }
580
+ } catch {
581
+ /* ignore */
582
+ }
583
+ try {
584
+ if (server) await server.close();
585
+ } catch {
586
+ /* ignore */
587
+ }
588
+ }
589
+
590
+ const failed = diagnostics.some((d) => d.severity === 'error');
591
+ return {
592
+ status: failed ? 'failed' : 'passed',
593
+ diagnostics,
594
+ planId: null,
595
+ programId,
596
+ };
597
+ }
598
+
599
+ /** Resolve chrome path (for gates / diagnostics). */
600
+ export function resolveBrowserExecutable(): string | null {
601
+ return findChromeExecutable();
602
+ }