@vmz/test 0.0.4 → 0.1.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/dist/browser-evidence.d.ts +32 -0
- package/dist/browser-evidence.js +51 -0
- package/dist/browser-protocol.d.ts +56 -0
- package/dist/browser-protocol.js +248 -0
- package/dist/browser-serve.d.ts +19 -0
- package/dist/browser-serve.js +148 -0
- package/dist/browser.d.ts +6 -3
- package/dist/browser.js +621 -164
- package/dist/compile.js +7 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +3 -3
package/dist/browser.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser Host for `vmz test --mode browser` (
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
304
|
+
page = await browser.newPage();
|
|
271
305
|
page.setDefaultTimeout(15000);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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;
|
|
451
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;
|
|
550
|
+
}
|
|
551
|
+
recordStep('assertion', kind, started, stepOk);
|
|
552
|
+
continue;
|
|
452
553
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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 (
|
|
499
|
-
|
|
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 (
|
|
502
|
-
|
|
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 (
|
|
505
|
-
|
|
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 (
|
|
508
|
-
|
|
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 (
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
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 (
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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 (
|
|
523
|
-
|
|
524
|
-
|
|
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;
|
|
823
|
+
}
|
|
824
|
+
if (kind === 'graph' || kind === 'plan' || kind === 'diagnostic' || kind === 'view' || kind === 'motion') {
|
|
825
|
+
recordStep('assertion', kind, started, true);
|
|
826
|
+
continue;
|
|
526
827
|
}
|
|
527
|
-
|
|
828
|
+
fail(`unknown browser assertion ${JSON.stringify(kind)}`);
|
|
829
|
+
recordStep('assertion', kind, started, false);
|
|
528
830
|
}
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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,120 @@ 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"]') || document.activeElement;
|
|
981
|
+
if (!el)
|
|
982
|
+
return false;
|
|
983
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: String(k), bubbles: true }));
|
|
984
|
+
el.dispatchEvent(new KeyboardEvent('keyup', { key: String(k), bubbles: true }));
|
|
985
|
+
return true;
|
|
986
|
+
}, key);
|
|
987
|
+
if (!ok)
|
|
988
|
+
throw new Error('press: no target/focused element');
|
|
989
|
+
}
|
|
990
|
+
async function pageText(page) {
|
|
991
|
+
return (await page.evaluate(() => {
|
|
992
|
+
const ctx = window.__vmzBrowser;
|
|
993
|
+
if (ctx?.app)
|
|
994
|
+
return ctx.app.textContent || '';
|
|
995
|
+
return document.body?.innerText || document.body?.textContent || '';
|
|
996
|
+
}));
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Native <select> or listbox/combobox (data-vmz-option / role=option).
|
|
1000
|
+
* Prefer option value (data-vmz-option) then accessible name/label.
|
|
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
|
+
const expanded = el.getAttribute('aria-expanded');
|
|
1019
|
+
if (expanded !== 'true')
|
|
1020
|
+
el.click();
|
|
1021
|
+
return { ok: true, kind: 'custom' };
|
|
1022
|
+
}, want);
|
|
1023
|
+
if (!native || !native.ok) {
|
|
1024
|
+
throw new Error(`select: ${native?.reason || 'failed'}`);
|
|
1025
|
+
}
|
|
1026
|
+
if (native.kind === 'native')
|
|
1027
|
+
return;
|
|
1028
|
+
// Prefer stable option value contract, then accessible name.
|
|
1029
|
+
const byValue = { kind: 'css', selector: `[data-vmz-option="${want.replace(/"/g, '\\"')}"]` };
|
|
1030
|
+
try {
|
|
1031
|
+
await waitForLocator(page, byValue, opts);
|
|
1032
|
+
await clickTarget(page);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
/* fall through to role=option name */
|
|
1037
|
+
}
|
|
1038
|
+
const optionLocator = { kind: 'role', role: 'option', name: want };
|
|
1039
|
+
await waitForLocator(page, optionLocator, opts);
|
|
1040
|
+
await clickTarget(page);
|
|
1041
|
+
}
|