@vmz/test 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Browser Host for `vmz test --mode browser` ( close slice).
2
+ * Browser Host for `vmz test --mode browser` (U0–U2 thin).
3
3
  *
4
4
  * Real Chromium/Chrome via CDP. Transport may use puppeteer-core as a CDP
5
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).
6
+ * and assertions remain the VMZ Browser Host protocol.
8
7
  *
8
+ * U0: Locator / Action / Expectation dispatcher (browser-protocol.ts).
9
+ * U1: role/label/text/testId; click/fill/press/select; actionability + auto-wait.
10
+ * U2: real serve-host + RouteId open/navigate; console/request fail gate;
11
+ * wall-clock timing + failure screenshot/DOM (not full U3 artifact pack).
9
12
  */
10
13
  import { spawn } from 'node:child_process';
11
14
  import fs from 'node:fs';
@@ -14,6 +17,9 @@ import net from 'node:net';
14
17
  import os from 'node:os';
15
18
  import path from 'node:path';
16
19
  import { resolveChunkArtifacts } from './compile.js';
20
+ import { createArtifactsDir, writeFailureEvidence, writeTimingOnly, } from './browser-evidence.js';
21
+ import { isServeHostManifest, resolveRoutePath, startServeHost } from './browser-serve.js';
22
+ import { defaultClickLocator, parseActionLocator, resolveLocatorInPage, sleep, } from './browser-protocol.js';
17
23
  const MIME = {
18
24
  '.html': 'text/html; charset=utf-8',
19
25
  '.js': 'text/javascript; charset=utf-8',
@@ -170,13 +176,24 @@ export async function runBrowserManifest(manifest, ctx) {
170
176
  const program = manifest.program && typeof manifest.program === 'object' ? manifest.program : {};
171
177
  const chunkId = String(program.chunkId || '');
172
178
  const programId = chunkId || null;
179
+ const useServe = isServeHostManifest(manifest);
180
+ const testId = String(manifest.id || 'anonymous');
181
+ const profile = manifest.profile && typeof manifest.profile === 'object' ? manifest.profile : {};
182
+ const failOnConsoleError = profile.failOnConsoleError !== false && (useServe || profile.failOnConsoleError === true);
183
+ const failOnRequestFailed = profile.failOnRequestFailed !== false && (useServe || profile.failOnRequestFailed === true);
173
184
  if (!chunkId) {
174
185
  fail('program.chunkId missing');
175
186
  return { status: 'error', diagnostics, planId: null, programId: null };
176
187
  }
177
- const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
178
- if (!arts.clientPath) {
179
- fail(`missing ${chunkId}.client.js`);
188
+ if (!useServe) {
189
+ const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
190
+ if (!arts.clientPath) {
191
+ fail(`missing ${chunkId}.client.js`);
192
+ return { status: 'failed', diagnostics, planId: null, programId };
193
+ }
194
+ }
195
+ else if (!fs.existsSync(path.join(ctx.outDir, 'vmz-serve-host.mjs'))) {
196
+ fail(`serve host: missing vmz-serve-host.mjs under ${ctx.outDir}`);
180
197
  return { status: 'failed', diagnostics, planId: null, programId };
181
198
  }
182
199
  const chrome = findChromeExecutable();
@@ -185,13 +202,30 @@ export async function runBrowserManifest(manifest, ctx) {
185
202
  return { status: 'error', diagnostics, planId: null, programId };
186
203
  }
187
204
  let server = null;
205
+ let serveHost = null;
188
206
  let browser = null;
189
207
  let profileDir = null;
190
208
  let chromeChild = null;
209
+ let page = null;
210
+ const stepTimings = [];
211
+ const runStarted = Date.now();
212
+ const consoleErrors = [];
213
+ const failedRequests = [];
214
+ const artifactsDir = createArtifactsDir(ctx.outDir, testId);
215
+ const recordStep = (phase, kind, started, ok, detail) => {
216
+ stepTimings.push({ phase, kind, ms: Date.now() - started, ok, detail });
217
+ };
191
218
  try {
192
219
  const puppeteer = await loadPuppeteerCore();
193
- server = await startStaticServer(ctx.outDir);
194
- const origin = `http://127.0.0.1:${server.port}`;
220
+ let origin;
221
+ if (useServe) {
222
+ serveHost = await startServeHost(ctx.outDir);
223
+ origin = serveHost.origin;
224
+ }
225
+ else {
226
+ server = await startStaticServer(ctx.outDir);
227
+ origin = `http://127.0.0.1:${server.port}`;
228
+ }
195
229
  // CI: spawn+connect first (puppeteer.launch often "Connection closed" on Chrome for Testing).
196
230
  const ci = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
197
231
  const commonArgs = [
@@ -267,56 +301,108 @@ export async function runBrowserManifest(manifest, ctx) {
267
301
  if (!browser) {
268
302
  throw lastLaunchErr instanceof Error ? lastLaunchErr : new Error(`browser launch failed: ${String(lastLaunchErr)}`);
269
303
  }
270
- const page = await browser.newPage();
304
+ page = await browser.newPage();
271
305
  page.setDefaultTimeout(15000);
272
- await page.goto(`${origin}/__vmz/harness`, { waitUntil: 'domcontentloaded' });
273
- const components = program.components && typeof program.components === 'object' ? program.components : {};
274
- const boot = await page.evaluate(async (cfg) => {
275
- const dom = await import(/* @vite-ignore */ `${cfg.origin}/vmz-dom.js`);
276
- const Comp = (await import(/* @vite-ignore */ `${cfg.origin}/${cfg.chunkPath}.client.js`)).default;
277
- const map = {};
278
- for (const [name, chunk] of Object.entries(cfg.components)) {
279
- map[name] = (await import(/* @vite-ignore */ `${cfg.origin}/${chunk}.client.js`)).default;
280
- }
281
- if (Object.keys(map).length && typeof dom.registerComponents === 'function') {
282
- dom.registerComponents(map);
283
- }
284
- const app = document.getElementById('app');
285
- if (!app)
286
- return { ok: false, error: '#app missing' };
287
- if (!Comp?.__vmzDirect || typeof Comp.__vmzCreate !== 'function') {
288
- return { ok: false, error: 'Direct __vmzCreate required' };
289
- }
290
- window.__vmzBrowser = {
291
- dom,
292
- Comp,
293
- app,
294
- inst: null,
295
- buttonBefore: null,
296
- capturedChild: null,
297
- lastPrecision: null,
298
- };
299
- return { ok: true };
300
- }, {
301
- origin,
302
- chunkPath: chunkId.replace(/\\/g, '/'),
303
- components,
306
+ page.on('console', (msg) => {
307
+ if (msg.type() === 'error')
308
+ consoleErrors.push(msg.text());
304
309
  });
305
- if (!boot?.ok) {
306
- fail(`browser boot: ${boot?.error || 'unknown'}`);
307
- return {
308
- status: 'error',
309
- diagnostics,
310
- planId: null,
311
- programId,
312
- };
310
+ page.on('pageerror', (err) => {
311
+ consoleErrors.push(err instanceof Error ? err.message : String(err));
312
+ });
313
+ page.on('requestfailed', (req) => {
314
+ const url = req.url();
315
+ const rt = typeof req.resourceType === 'function' ? req.resourceType() : '';
316
+ if (url.includes('favicon') || rt === 'image' || rt === 'media' || rt === 'font')
317
+ return;
318
+ const why = req.failure()?.errorText || 'failed';
319
+ // SPA client nav often aborts in-flight document; ignore benign aborts.
320
+ if (why.includes('ERR_ABORTED') || why.includes('net::ERR_ABORTED'))
321
+ return;
322
+ failedRequests.push(`${url} (${why})`);
323
+ });
324
+ if (useServe) {
325
+ page.__vmzServeOrigin = origin;
326
+ page.__vmzServeMode = true;
327
+ }
328
+ else {
329
+ await page.goto(`${origin}/__vmz/harness`, { waitUntil: 'domcontentloaded' });
330
+ const components = program.components && typeof program.components === 'object' ? program.components : {};
331
+ const boot = await page.evaluate(async (cfg) => {
332
+ const dom = await import(/* @vite-ignore */ `${cfg.origin}/vmz-dom.js`);
333
+ const Comp = (await import(/* @vite-ignore */ `${cfg.origin}/${cfg.chunkPath}.client.js`)).default;
334
+ const map = {};
335
+ for (const [name, chunk] of Object.entries(cfg.components)) {
336
+ map[name] = (await import(/* @vite-ignore */ `${cfg.origin}/${chunk}.client.js`)).default;
337
+ }
338
+ if (Object.keys(map).length && typeof dom.registerComponents === 'function') {
339
+ dom.registerComponents(map);
340
+ }
341
+ const app = document.getElementById('app');
342
+ if (!app)
343
+ return { ok: false, error: '#app missing' };
344
+ if (!Comp?.__vmzDirect || typeof Comp.__vmzCreate !== 'function') {
345
+ return { ok: false, error: 'Direct __vmzCreate required' };
346
+ }
347
+ window.__vmzBrowser = {
348
+ dom,
349
+ Comp,
350
+ app,
351
+ inst: null,
352
+ buttonBefore: null,
353
+ capturedChild: null,
354
+ lastPrecision: null,
355
+ };
356
+ return { ok: true };
357
+ }, {
358
+ origin,
359
+ chunkPath: chunkId.replace(/\\/g, '/'),
360
+ components,
361
+ });
362
+ if (!boot?.ok) {
363
+ fail(`browser boot: ${boot?.error || 'unknown'}`);
364
+ return {
365
+ status: 'error',
366
+ diagnostics,
367
+ planId: null,
368
+ programId,
369
+ };
370
+ }
313
371
  }
314
372
  const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
315
373
  for (const raw of actions) {
316
374
  const a = raw && typeof raw === 'object' ? raw : {};
317
375
  const kind = String(a.kind || '');
376
+ const started = Date.now();
377
+ let stepOk = true;
318
378
  try {
379
+ if (kind === 'open' || kind === 'navigate') {
380
+ const pathname = resolveRoutePath(ctx.outDir, {
381
+ routeId: a.routeId != null ? String(a.routeId) : undefined,
382
+ path: a.path != null ? String(a.path) : undefined,
383
+ params: a.params && typeof a.params === 'object'
384
+ ? Object.fromEntries(Object.entries(a.params).map(([k, v]) => [k, String(v)]))
385
+ : undefined,
386
+ });
387
+ const url = new URL(pathname, origin).toString();
388
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
389
+ const timeoutMs = Number(a.timeoutMs) > 0 ? Number(a.timeoutMs) : 8000;
390
+ const deadline = Date.now() + timeoutMs;
391
+ while (Date.now() <= deadline) {
392
+ const loc = await page.evaluate(() => ({
393
+ path: location.pathname,
394
+ ready: document.readyState,
395
+ }));
396
+ if (loc.path === pathname.split('?')[0] || loc.path.endsWith(pathname.split('?')[0]))
397
+ break;
398
+ await sleep(40);
399
+ }
400
+ recordStep('action', kind, started, true, pathname);
401
+ continue;
402
+ }
319
403
  if (kind === 'mount') {
404
+ if (useServe)
405
+ throw new Error('mount is for Direct harness only (not serve-host)');
320
406
  const props = a.props && typeof a.props === 'object' ? a.props : {};
321
407
  const r = await page.evaluate(async (p) => {
322
408
  const ctx = window.__vmzBrowser;
@@ -333,21 +419,35 @@ export async function runBrowserManifest(manifest, ctx) {
333
419
  }, props);
334
420
  if (r.createHits !== 1)
335
421
  fail(`mount must call __vmzCreate once, got ${r.createHits}`);
422
+ recordStep('action', kind, started, true);
336
423
  continue;
337
424
  }
338
- if (kind === 'click') {
339
- const selector = typeof a.selector === 'string' ? a.selector : 'button';
340
- // Real browser input path: Element.click in page (not linkedom).
341
- const ok = await page.evaluate((sel) => {
342
- const ctx = window.__vmzBrowser;
343
- const el = ctx.app.querySelector(sel);
344
- if (!el)
345
- return false;
346
- el.click();
347
- return true;
348
- }, selector);
349
- if (!ok)
350
- fail(`click: no element for ${JSON.stringify(selector)}`);
425
+ if (kind === 'click' || kind === 'fill' || kind === 'press' || kind === 'select') {
426
+ const parsed = parseActionLocator(a);
427
+ for (const w of parsed.warnings) {
428
+ diagnostics.push({ severity: 'warning', message: w });
429
+ }
430
+ let locator = parsed.locator;
431
+ if (!locator && kind === 'click')
432
+ locator = defaultClickLocator();
433
+ if (!locator) {
434
+ fail(`${kind}: locator or legacy selector required`);
435
+ stepOk = false;
436
+ recordStep('action', kind, started, false);
437
+ continue;
438
+ }
439
+ const timeoutMs = Number(a.timeoutMs) > 0 ? Number(a.timeoutMs) : 8000;
440
+ const force = a.force === true;
441
+ await waitForLocator(page, locator, { timeoutMs, force });
442
+ if (kind === 'click')
443
+ await clickTarget(page);
444
+ else if (kind === 'fill')
445
+ await fillTarget(page, a.value);
446
+ else if (kind === 'press')
447
+ await pressTarget(page, a.key ?? a.value ?? 'Enter');
448
+ else
449
+ await selectTarget(page, a.value ?? a.option, { timeoutMs, force });
450
+ recordStep('action', kind, started, true);
351
451
  continue;
352
452
  }
353
453
  if (kind === 'write') {
@@ -358,6 +458,7 @@ export async function runBrowserManifest(manifest, ctx) {
358
458
  throw new Error('write before mount');
359
459
  ctx.inst[args.field] = args.value;
360
460
  }, { field, value: a.value });
461
+ recordStep('action', kind, started, true);
361
462
  continue;
362
463
  }
363
464
  if (kind === 'flush') {
@@ -367,6 +468,7 @@ export async function runBrowserManifest(manifest, ctx) {
367
468
  throw new Error('flush before mount');
368
469
  await ctx.dom.flushPending(ctx.inst);
369
470
  });
471
+ recordStep('action', kind, started, true);
370
472
  continue;
371
473
  }
372
474
  if (kind === 'destroy') {
@@ -376,6 +478,7 @@ export async function runBrowserManifest(manifest, ctx) {
376
478
  throw new Error('destroy before mount');
377
479
  ctx.dom.destroy(ctx.inst);
378
480
  });
481
+ recordStep('action', kind, started, true);
379
482
  continue;
380
483
  }
381
484
  if (kind === 'capture_child') {
@@ -390,6 +493,7 @@ export async function runBrowserManifest(manifest, ctx) {
390
493
  }, selector);
391
494
  if (!ok)
392
495
  fail(`capture_child: no inst for ${JSON.stringify(selector)}`);
496
+ recordStep('action', kind, started, ok);
393
497
  continue;
394
498
  }
395
499
  if (kind === 'precision_reset') {
@@ -400,142 +504,371 @@ export async function runBrowserManifest(manifest, ctx) {
400
504
  if (typeof ctx.dom.__vmzPrecisionReset === 'function')
401
505
  ctx.dom.__vmzPrecisionReset();
402
506
  });
507
+ recordStep('action', kind, started, true);
403
508
  continue;
404
509
  }
405
510
  fail(`unknown browser action ${JSON.stringify(kind)}`);
511
+ stepOk = false;
512
+ recordStep('action', kind, started, false);
406
513
  }
407
514
  catch (e) {
515
+ stepOk = false;
516
+ recordStep('action', kind, started, false, e instanceof Error ? e.message : String(e));
408
517
  fail(`action ${kind}: ${e instanceof Error ? e.message : String(e)}`);
409
518
  }
519
+ void stepOk;
410
520
  }
411
521
  const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
412
522
  for (const raw of assertions) {
413
523
  const a = raw && typeof raw === 'object' ? raw : {};
414
524
  const kind = String(a.kind || '');
415
525
  const expect = a.expect && typeof a.expect === 'object' ? a.expect : {};
416
- if (kind === 'text') {
417
- const text = await page.evaluate(() => {
418
- const ctx = window.__vmzBrowser;
419
- return ctx.app.textContent || '';
420
- });
421
- if (expect.equals != null && text !== String(expect.equals)) {
422
- fail(`text equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(text)}`);
423
- }
424
- if (expect.contains != null && !text.includes(String(expect.contains))) {
425
- fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
426
- }
427
- continue;
428
- }
429
- if (kind === 'nodeIdentity') {
430
- const sel = typeof expect.selector === 'string' ? expect.selector : 'button';
431
- const same = await page.evaluate((s) => {
432
- const ctx = window.__vmzBrowser;
433
- const after = ctx.app.querySelector(s);
434
- return !!(ctx.buttonBefore && after && after === ctx.buttonBefore);
435
- }, sel);
436
- if (!same)
437
- fail(`nodeIdentity failed for ${sel} (real browser document)`);
438
- continue;
439
- }
440
- if (kind === 'state') {
441
- const state = await page.evaluate((keys) => {
442
- const ctx = window.__vmzBrowser;
443
- const out = {};
444
- for (const k of keys)
445
- out[k] = ctx.inst?.[k];
446
- return out;
447
- }, Object.keys(expect));
448
- for (const [k, v] of Object.entries(expect)) {
449
- if (state[k] !== v) {
450
- fail(`state.${k} want ${JSON.stringify(v)}, got ${JSON.stringify(state[k])}`);
526
+ const started = Date.now();
527
+ let stepOk = true;
528
+ try {
529
+ if (kind === 'text') {
530
+ const timeoutMs = Number(a.timeoutMs ?? expect.timeoutMs) > 0 ? Number(a.timeoutMs ?? expect.timeoutMs) : 8000;
531
+ const deadline = Date.now() + timeoutMs;
532
+ let text = '';
533
+ while (Date.now() <= deadline) {
534
+ text = await pageText(page);
535
+ if (expect.equals != null && text === String(expect.equals))
536
+ break;
537
+ if (expect.contains != null && text.includes(String(expect.contains)))
538
+ break;
539
+ if (expect.equals == null && expect.contains == null)
540
+ break;
541
+ await sleep(40);
542
+ }
543
+ if (expect.equals != null && text !== String(expect.equals)) {
544
+ fail(`text equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(text)}`);
545
+ stepOk = false;
546
+ }
547
+ if (expect.contains != null && !text.includes(String(expect.contains))) {
548
+ fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
549
+ stepOk = false;
451
550
  }
551
+ recordStep('assertion', kind, started, stepOk);
552
+ continue;
452
553
  }
453
- continue;
454
- }
455
- if (kind === 'host') {
456
- if (expect.kind === 'browser' || expect.realDocument === true) {
457
- const ok = await page.evaluate(() => typeof document !== 'undefined' && !!document.createElement);
458
- if (!ok)
459
- fail('host.realDocument failed');
554
+ if (kind === 'route') {
555
+ const timeoutMs = Number(a.timeoutMs ?? expect.timeoutMs) > 0 ? Number(a.timeoutMs ?? expect.timeoutMs) : 8000;
556
+ const deadline = Date.now() + timeoutMs;
557
+ let loc = { path: '', href: '' };
558
+ const wantPath = expect.path != null ? String(expect.path) : null;
559
+ const wantContains = expect.pathContains != null ? String(expect.pathContains) : null;
560
+ const wantRouteId = expect.routeId != null ? String(expect.routeId) : null;
561
+ while (Date.now() <= deadline) {
562
+ loc = await page.evaluate(() => ({ path: location.pathname, href: location.href }));
563
+ let ok = true;
564
+ if (wantPath != null && loc.path !== wantPath)
565
+ ok = false;
566
+ if (wantContains != null && !loc.path.includes(wantContains) && !loc.href.includes(wantContains))
567
+ ok = false;
568
+ if (wantRouteId != null) {
569
+ const hit = await page.evaluate((id) => {
570
+ const el = document.querySelector(`[data-vmz-route="${CSS.escape(id)}"]`);
571
+ return !!el;
572
+ }, wantRouteId);
573
+ if (!hit && loc.path) {
574
+ try {
575
+ const resolved = resolveRoutePath(ctx.outDir, { routeId: wantRouteId });
576
+ if (loc.path !== resolved && !loc.path.endsWith(resolved))
577
+ ok = false;
578
+ else
579
+ ok = true;
580
+ }
581
+ catch {
582
+ ok = hit;
583
+ }
584
+ }
585
+ else if (!hit)
586
+ ok = false;
587
+ }
588
+ if (ok)
589
+ break;
590
+ await sleep(40);
591
+ }
592
+ if (wantPath != null && loc.path !== wantPath) {
593
+ fail(`route.path want ${wantPath}, got ${loc.path}`);
594
+ stepOk = false;
595
+ }
596
+ if (wantContains != null && !loc.path.includes(wantContains) && !loc.href.includes(wantContains)) {
597
+ fail(`route.pathContains want ${wantContains}, got ${loc.path}`);
598
+ stepOk = false;
599
+ }
600
+ recordStep('assertion', kind, started, stepOk, loc.path);
601
+ continue;
460
602
  }
461
- continue;
462
- }
463
- if (kind === 'destroyed') {
464
- const want = expect.value !== false;
465
- const got = await page.evaluate(() => {
466
- const ctx = window.__vmzBrowser;
467
- return Boolean(ctx.inst?.__vmzDestroyed);
468
- });
469
- if (got !== want)
470
- fail(`__vmzDestroyed want ${want}, got ${got}`);
471
- continue;
472
- }
473
- if (kind === 'childDestroyed') {
474
- const want = expect.value !== false;
475
- const got = await page.evaluate(() => {
476
- const ctx = window.__vmzBrowser;
477
- if (!ctx.capturedChild)
478
- return null;
479
- return Boolean(ctx.capturedChild.__vmzDestroyed);
480
- });
481
- if (got == null)
482
- fail('childDestroyed: no captured child (use capture_child action)');
483
- else if (got !== want)
484
- fail(`child __vmzDestroyed want ${want}, got ${got}`);
485
- continue;
486
- }
487
- if (kind === 'precision') {
488
- const snap = await page.evaluate(() => {
489
- const ctx = window.__vmzBrowser;
490
- if (typeof ctx.dom.__vmzPrecisionSnapshot !== 'function')
491
- return null;
492
- return ctx.dom.__vmzPrecisionSnapshot();
493
- });
494
- if (!snap) {
495
- fail('precision snapshot unavailable');
603
+ if (kind === 'visible' || kind === 'count' || kind === 'value') {
604
+ const fromAssert = parseActionLocator({
605
+ locator: a.locator ?? expect.locator,
606
+ selector: a.selector ?? expect.selector,
607
+ });
608
+ for (const w of fromAssert.warnings) {
609
+ diagnostics.push({ severity: 'warning', message: w });
610
+ }
611
+ if (!fromAssert.locator) {
612
+ fail(`${kind}: locator or legacy selector required`);
613
+ recordStep('assertion', kind, started, false);
614
+ continue;
615
+ }
616
+ const timeoutMs = Number(a.timeoutMs ?? expect.timeoutMs) > 0 ? Number(a.timeoutMs ?? expect.timeoutMs) : 8000;
617
+ const deadline = Date.now() + timeoutMs;
618
+ let last = {};
619
+ while (Date.now() <= deadline) {
620
+ last = await page.evaluate(resolveLocatorInPage, fromAssert.locator, { force: true });
621
+ if (kind === 'visible') {
622
+ if (Number(last?.count) >= 1)
623
+ break;
624
+ }
625
+ else if (kind === 'count') {
626
+ const want = Number(expect.equals ?? expect.count);
627
+ if (Number.isFinite(want) && Number(last?.count) === want)
628
+ break;
629
+ }
630
+ else if (kind === 'value') {
631
+ if (last?.ok && last.count === 1) {
632
+ const val = await page.evaluate(() => {
633
+ const el = document.querySelector('[data-vmz-bh-target="1"]');
634
+ return el ? String(el.value) : null;
635
+ });
636
+ last.value = val;
637
+ if (expect.equals != null && val === String(expect.equals))
638
+ break;
639
+ if (expect.contains != null && val != null && val.includes(String(expect.contains)))
640
+ break;
641
+ if (expect.equals == null && expect.contains == null)
642
+ break;
643
+ }
644
+ }
645
+ else
646
+ break;
647
+ await sleep(40);
648
+ }
649
+ if (kind === 'visible') {
650
+ if (!(Number(last?.count) >= 1)) {
651
+ fail(`visible: ${last?.reason || 'not found'} ${JSON.stringify(fromAssert.locator)}`);
652
+ stepOk = false;
653
+ }
654
+ }
655
+ else if (kind === 'count') {
656
+ const want = Number(expect.equals ?? expect.count);
657
+ if (!Number.isFinite(want) || Number(last?.count) !== want) {
658
+ fail(`count want ${want}, got ${last?.count} (${last?.reason || ''})`);
659
+ stepOk = false;
660
+ }
661
+ }
662
+ else if (kind === 'value') {
663
+ const val = last.value ??
664
+ (await page.evaluate(() => {
665
+ const el = document.querySelector('[data-vmz-bh-target="1"]');
666
+ return el ? String(el.value) : null;
667
+ }));
668
+ if (expect.equals != null && val !== String(expect.equals)) {
669
+ fail(`value equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(val)}`);
670
+ stepOk = false;
671
+ }
672
+ if (expect.contains != null && (val == null || !String(val).includes(String(expect.contains)))) {
673
+ fail(`value contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(val)}`);
674
+ stepOk = false;
675
+ }
676
+ }
677
+ recordStep('assertion', kind, started, stepOk);
496
678
  continue;
497
679
  }
498
- if (expect.minWrites != null && Number(snap.writes || 0) < Number(expect.minWrites)) {
499
- fail(`precision.writes want >= ${expect.minWrites}, got ${snap.writes}`);
680
+ if (kind === 'nodeIdentity') {
681
+ const sel = typeof expect.selector === 'string' ? expect.selector : 'button';
682
+ const same = await page.evaluate((s) => {
683
+ const ctx = window.__vmzBrowser;
684
+ const after = ctx.app.querySelector(s);
685
+ return !!(ctx.buttonBefore && after && after === ctx.buttonBefore);
686
+ }, sel);
687
+ if (!same) {
688
+ fail(`nodeIdentity failed for ${sel} (real browser document)`);
689
+ stepOk = false;
690
+ }
691
+ recordStep('assertion', kind, started, stepOk);
692
+ continue;
500
693
  }
501
- if (expect.maxWrites != null && Number(snap.writes || 0) > Number(expect.maxWrites)) {
502
- fail(`precision.writes want <= ${expect.maxWrites}, got ${snap.writes}`);
694
+ if (kind === 'state') {
695
+ const state = await page.evaluate((keys) => {
696
+ const ctx = window.__vmzBrowser;
697
+ const out = {};
698
+ for (const k of keys)
699
+ out[k] = ctx.inst?.[k];
700
+ return out;
701
+ }, Object.keys(expect));
702
+ for (const [k, v] of Object.entries(expect)) {
703
+ if (state[k] !== v) {
704
+ fail(`state.${k} want ${JSON.stringify(v)}, got ${JSON.stringify(state[k])}`);
705
+ stepOk = false;
706
+ }
707
+ }
708
+ recordStep('assertion', kind, started, stepOk);
709
+ continue;
503
710
  }
504
- if (expect.maxBindingEvals != null && Number(snap.bindingEvals || 0) > Number(expect.maxBindingEvals)) {
505
- fail(`precision.bindingEvals want <= ${expect.maxBindingEvals}, got ${snap.bindingEvals}`);
711
+ if (kind === 'host') {
712
+ if (expect.kind === 'browser' || expect.realDocument === true) {
713
+ const ok = await page.evaluate(() => typeof document !== 'undefined' && !!document.createElement);
714
+ if (!ok) {
715
+ fail('host.realDocument failed');
716
+ stepOk = false;
717
+ }
718
+ }
719
+ if (expect.serveHost === true && !useServe) {
720
+ fail('host.serveHost expected but manifest used static harness');
721
+ stepOk = false;
722
+ }
723
+ recordStep('assertion', kind, started, stepOk);
724
+ continue;
506
725
  }
507
- if (expect.maxPatchExecs != null && Number(snap.patchExecs || 0) > Number(expect.maxPatchExecs)) {
508
- fail(`precision.patchExecs want <= ${expect.maxPatchExecs}, got ${snap.patchExecs}`);
726
+ if (kind === 'destroyed') {
727
+ const want = expect.value !== false;
728
+ const got = await page.evaluate(() => {
729
+ const ctx = window.__vmzBrowser;
730
+ return Boolean(ctx.inst?.__vmzDestroyed);
731
+ });
732
+ if (got !== want) {
733
+ fail(`__vmzDestroyed want ${want}, got ${got}`);
734
+ stepOk = false;
735
+ }
736
+ recordStep('assertion', kind, started, stepOk);
737
+ continue;
509
738
  }
510
- if (expect.patchesIncludeDep != null) {
511
- const dep = String(expect.patchesIncludeDep);
512
- const map = snap.patchesByDep || {};
513
- if (!map[dep])
514
- fail(`precision.patchesByDep missing ${dep}: ${JSON.stringify(map)}`);
739
+ if (kind === 'childDestroyed') {
740
+ const want = expect.value !== false;
741
+ const got = await page.evaluate(() => {
742
+ const ctx = window.__vmzBrowser;
743
+ if (!ctx.capturedChild)
744
+ return null;
745
+ return Boolean(ctx.capturedChild.__vmzDestroyed);
746
+ });
747
+ if (got == null) {
748
+ fail('childDestroyed: no captured child (use capture_child action)');
749
+ stepOk = false;
750
+ }
751
+ else if (got !== want) {
752
+ fail(`child __vmzDestroyed want ${want}, got ${got}`);
753
+ stepOk = false;
754
+ }
755
+ recordStep('assertion', kind, started, stepOk);
756
+ continue;
515
757
  }
516
- if (expect.writesIncludeRoot != null) {
517
- const rootKey = String(expect.writesIncludeRoot);
518
- const map = snap.writesByRoot || {};
519
- if (!map[rootKey])
520
- fail(`precision.writesByRoot missing ${rootKey}: ${JSON.stringify(map)}`);
758
+ if (kind === 'precision') {
759
+ const snap = await page.evaluate(() => {
760
+ const ctx = window.__vmzBrowser;
761
+ if (typeof ctx.dom.__vmzPrecisionSnapshot !== 'function')
762
+ return null;
763
+ return ctx.dom.__vmzPrecisionSnapshot();
764
+ });
765
+ if (!snap) {
766
+ fail('precision snapshot unavailable');
767
+ recordStep('assertion', kind, started, false);
768
+ continue;
769
+ }
770
+ if (expect.minWrites != null && Number(snap.writes || 0) < Number(expect.minWrites)) {
771
+ fail(`precision.writes want >= ${expect.minWrites}, got ${snap.writes}`);
772
+ stepOk = false;
773
+ }
774
+ if (expect.maxWrites != null && Number(snap.writes || 0) > Number(expect.maxWrites)) {
775
+ fail(`precision.writes want <= ${expect.maxWrites}, got ${snap.writes}`);
776
+ stepOk = false;
777
+ }
778
+ if (expect.maxBindingEvals != null && Number(snap.bindingEvals || 0) > Number(expect.maxBindingEvals)) {
779
+ fail(`precision.bindingEvals want <= ${expect.maxBindingEvals}, got ${snap.bindingEvals}`);
780
+ stepOk = false;
781
+ }
782
+ if (expect.maxPatchExecs != null && Number(snap.patchExecs || 0) > Number(expect.maxPatchExecs)) {
783
+ fail(`precision.patchExecs want <= ${expect.maxPatchExecs}, got ${snap.patchExecs}`);
784
+ stepOk = false;
785
+ }
786
+ if (expect.patchesIncludeDep != null) {
787
+ const dep = String(expect.patchesIncludeDep);
788
+ const map = snap.patchesByDep || {};
789
+ if (!map[dep]) {
790
+ fail(`precision.patchesByDep missing ${dep}: ${JSON.stringify(map)}`);
791
+ stepOk = false;
792
+ }
793
+ }
794
+ if (expect.writesIncludeRoot != null) {
795
+ const rootKey = String(expect.writesIncludeRoot);
796
+ const map = snap.writesByRoot || {};
797
+ if (!map[rootKey]) {
798
+ fail(`precision.writesByRoot missing ${rootKey}: ${JSON.stringify(map)}`);
799
+ stepOk = false;
800
+ }
801
+ }
802
+ if (expect.domCreates === 0 || expect.domCreates === false) {
803
+ if (Number(snap.domCreates || 0) !== 0) {
804
+ fail(`precision.domCreates want 0 after action window, got ${snap.domCreates}`);
805
+ stepOk = false;
806
+ }
807
+ }
808
+ recordStep('assertion', kind, started, stepOk);
809
+ continue;
521
810
  }
522
- if (expect.domCreates === 0 || expect.domCreates === false) {
523
- if (Number(snap.domCreates || 0) !== 0) {
524
- fail(`precision.domCreates want 0 after action window, got ${snap.domCreates}`);
811
+ if (kind === 'timing') {
812
+ // Presence of step timings is enough for thin evidence gate.
813
+ if (!stepTimings.length) {
814
+ fail('timing: no recorded steps');
815
+ stepOk = false;
525
816
  }
817
+ if (expect.minSteps != null && stepTimings.length < Number(expect.minSteps)) {
818
+ fail(`timing.minSteps want >= ${expect.minSteps}, got ${stepTimings.length}`);
819
+ stepOk = false;
820
+ }
821
+ recordStep('assertion', kind, started, stepOk);
822
+ continue;
526
823
  }
527
- continue;
824
+ if (kind === 'graph' || kind === 'plan' || kind === 'diagnostic' || kind === 'view' || kind === 'motion') {
825
+ recordStep('assertion', kind, started, true);
826
+ continue;
827
+ }
828
+ fail(`unknown browser assertion ${JSON.stringify(kind)}`);
829
+ recordStep('assertion', kind, started, false);
528
830
  }
529
- if (kind === 'graph' || kind === 'plan' || kind === 'diagnostic' || kind === 'view' || kind === 'motion') {
530
- continue;
831
+ catch (e) {
832
+ recordStep('assertion', kind, started, false, e instanceof Error ? e.message : String(e));
833
+ fail(`assertion ${kind}: ${e instanceof Error ? e.message : String(e)}`);
531
834
  }
532
- fail(`unknown browser assertion ${JSON.stringify(kind)}`);
835
+ }
836
+ if (failOnConsoleError && consoleErrors.length) {
837
+ fail(`console errors (${consoleErrors.length}): ${consoleErrors.slice(0, 3).join(' | ')}`);
838
+ }
839
+ if (failOnRequestFailed && failedRequests.length) {
840
+ fail(`request failed (${failedRequests.length}): ${failedRequests.slice(0, 3).join(' | ')}`);
533
841
  }
534
842
  }
535
843
  catch (e) {
536
844
  fail(e instanceof Error ? e.message : String(e));
537
845
  }
538
846
  finally {
847
+ const timing = {
848
+ schema: 'vmz.test.browser.timing.v0',
849
+ totalMs: Date.now() - runStarted,
850
+ steps: stepTimings,
851
+ };
852
+ const failed = diagnostics.some((d) => d.severity === 'error');
853
+ try {
854
+ if (failed && page) {
855
+ const paths = await writeFailureEvidence(page, artifactsDir, timing);
856
+ diagnostics.push({
857
+ severity: 'info',
858
+ message: `browser evidence: ${paths.timing || ''}${paths.screenshot ? `; screenshot ${paths.screenshot}` : ''}`,
859
+ });
860
+ }
861
+ else {
862
+ const timingPath = writeTimingOnly(artifactsDir, timing);
863
+ diagnostics.push({ severity: 'info', message: `browser timing: ${timingPath}` });
864
+ }
865
+ }
866
+ catch (e) {
867
+ diagnostics.push({
868
+ severity: 'warning',
869
+ message: `evidence write failed: ${e instanceof Error ? e.message : String(e)}`,
870
+ });
871
+ }
539
872
  try {
540
873
  if (browser) {
541
874
  if (chromeChild)
@@ -569,6 +902,13 @@ export async function runBrowserManifest(manifest, ctx) {
569
902
  catch {
570
903
  /* ignore */
571
904
  }
905
+ try {
906
+ if (serveHost)
907
+ serveHost.kill();
908
+ }
909
+ catch {
910
+ /* ignore */
911
+ }
572
912
  }
573
913
  const failed = diagnostics.some((d) => d.severity === 'error');
574
914
  return {
@@ -582,3 +922,108 @@ export async function runBrowserManifest(manifest, ctx) {
582
922
  export function resolveBrowserExecutable() {
583
923
  return findChromeExecutable();
584
924
  }
925
+ /**
926
+ * Auto-wait until locator resolves to exactly one actionable element.
927
+ */
928
+ async function waitForLocator(page, locator, opts = {}) {
929
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 8000;
930
+ const force = opts.force === true;
931
+ const deadline = Date.now() + timeoutMs;
932
+ let last = {
933
+ ok: false,
934
+ count: 0,
935
+ actionable: false,
936
+ reason: 'not attempted',
937
+ index: -1,
938
+ };
939
+ while (Date.now() <= deadline) {
940
+ last = (await page.evaluate(resolveLocatorInPage, locator, { force }));
941
+ if (last && last.ok && last.actionable && last.count === 1)
942
+ return last;
943
+ await sleep(40);
944
+ }
945
+ throw new Error(`locator timeout (${timeoutMs}ms): ${last?.reason || 'unknown'} count=${last?.count ?? 0} ${JSON.stringify(locator)}`);
946
+ }
947
+ async function clickTarget(page) {
948
+ const ok = await page.evaluate(() => {
949
+ const el = document.querySelector('[data-vmz-bh-target="1"]');
950
+ if (!el)
951
+ return false;
952
+ el.focus();
953
+ el.click();
954
+ return true;
955
+ });
956
+ if (!ok)
957
+ throw new Error('click: resolved target missing in document');
958
+ }
959
+ async function fillTarget(page, value) {
960
+ const ok = await page.evaluate((v) => {
961
+ const el = document.querySelector('[data-vmz-bh-target="1"]');
962
+ if (!el)
963
+ return false;
964
+ el.focus();
965
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
966
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
967
+ if (setter)
968
+ setter.call(el, String(v));
969
+ else
970
+ el.value = String(v);
971
+ el.dispatchEvent(new Event('input', { bubbles: true }));
972
+ el.dispatchEvent(new Event('change', { bubbles: true }));
973
+ return true;
974
+ }, value);
975
+ if (!ok)
976
+ throw new Error('fill: resolved target missing or not an input');
977
+ }
978
+ async function pressTarget(page, key) {
979
+ const ok = await page.evaluate((k) => {
980
+ const el = document.querySelector('[data-vmz-bh-target="1"]') ||
981
+ document.activeElement;
982
+ if (!el)
983
+ return false;
984
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: String(k), bubbles: true }));
985
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: String(k), bubbles: true }));
986
+ return true;
987
+ }, key);
988
+ if (!ok)
989
+ throw new Error('press: no target/focused element');
990
+ }
991
+ async function pageText(page) {
992
+ return (await page.evaluate(() => {
993
+ const ctx = window.__vmzBrowser;
994
+ if (ctx?.app)
995
+ return ctx.app.textContent || '';
996
+ return document.body?.innerText || document.body?.textContent || '';
997
+ }));
998
+ }
999
+ /**
1000
+ * Native <select> or listbox/combobox (data-vmz-option / role=option).
1001
+ */
1002
+ async function selectTarget(page, value, opts = {}) {
1003
+ const want = String(value ?? '');
1004
+ if (!want)
1005
+ throw new Error('select: value/option required');
1006
+ const native = await page.evaluate((v) => {
1007
+ const el = document.querySelector('[data-vmz-bh-target="1"]');
1008
+ if (!el)
1009
+ return { ok: false, reason: 'missing target' };
1010
+ if (el instanceof HTMLSelectElement) {
1011
+ el.focus();
1012
+ el.value = v;
1013
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1014
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1015
+ return { ok: true, kind: 'native' };
1016
+ }
1017
+ // Custom combobox/listbox: open if needed, then click option.
1018
+ el.click();
1019
+ return { ok: true, kind: 'custom' };
1020
+ }, want);
1021
+ if (!native || !native.ok) {
1022
+ throw new Error(`select: ${native?.reason || 'failed'}`);
1023
+ }
1024
+ if (native.kind === 'native')
1025
+ return;
1026
+ const optionLocator = { kind: 'role', role: 'option', name: want };
1027
+ await waitForLocator(page, optionLocator, opts);
1028
+ await clickTarget(page);
1029
+ }