@ai-ecoverse/slicc-browsers 1.7.2 → 1.7.4

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/guard-runner.mjs CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  expand,
5
5
  hop,
6
6
  isRefusal,
7
+ loadedAfter,
7
8
  outcome,
8
9
  summary as report,
9
10
  result,
@@ -11,6 +12,7 @@ import {
11
12
  } from './guard-driver.mjs';
12
13
 
13
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const namedResultMs = 10000;
14
16
  const q = JSON.stringify;
15
17
  const at = (value, path = '') =>
16
18
  path
@@ -108,6 +110,8 @@ class Execution {
108
110
  this.answers = [];
109
111
  this.attachSettings = new Map();
110
112
  this.childSteps = [];
113
+ this.childRuns = new Map();
114
+ this.named = new Map();
111
115
  this.processed = new Set();
112
116
  this.eventStart = this.client.events.length;
113
117
  this.actionEventStart = this.eventStart;
@@ -150,12 +154,9 @@ class Execution {
150
154
  async navigate(client, session, url) {
151
155
  const start = client.events.length;
152
156
  const out = await outcome(client.send('Page.navigate', { url }, session));
153
- if (out.ok && !out.result.errorText)
157
+ if (out.ok && !out.result.errorText && out.result.loaderId)
154
158
  await this.poll(
155
- () =>
156
- client.events
157
- .slice(start)
158
- .some((event) => event.method === 'Page.loadEventFired' && event.sessionId === session),
159
+ () => loadedAfter(client.events.slice(start), session, out.result.loaderId),
159
160
  3000
160
161
  );
161
162
  return out;
@@ -283,12 +284,99 @@ class Execution {
283
284
  tree.frame.url.startsWith('chrome-error://')) ||
284
285
  (tree.childFrames ?? []).some(visit))
285
286
  );
286
- return (
287
+ if (
287
288
  read.ok &&
288
289
  (spec.frame
289
290
  ? (read.result.frameTree.childFrames ?? []).some(visit)
290
291
  : visit(read.result.frameTree))
292
+ )
293
+ return true;
294
+ return spec.frame ? Boolean(await this.blockedFrameTarget(urls, targetId)) : false;
295
+ } finally {
296
+ await outcome(
297
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
298
+ );
299
+ }
300
+ }
301
+
302
+ async frameIds(targetId) {
303
+ const attached = await outcome(
304
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
305
+ );
306
+ if (!attached.ok) return [];
307
+ try {
308
+ const read = await outcome(
309
+ this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
291
310
  );
311
+ const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
312
+ return read.ok ? ids(read.result.frameTree) : [];
313
+ } finally {
314
+ await outcome(
315
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
316
+ );
317
+ }
318
+ }
319
+
320
+ async blockedFrameTarget(urls, parentTarget) {
321
+ const parents = await this.frameIds(parentTarget);
322
+ const listed = await outcome(this.reader.send('Target.getTargets', {}));
323
+ if (!listed.ok) return undefined;
324
+ for (const info of listed.result.targetInfos) {
325
+ if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
326
+ const attached = await outcome(
327
+ this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
328
+ );
329
+ if (!attached.ok) continue;
330
+ try {
331
+ const read = await outcome(
332
+ this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
333
+ );
334
+ const frame = read.result?.frameTree?.frame;
335
+ if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
336
+ return info.targetId;
337
+ } finally {
338
+ await outcome(
339
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
340
+ );
341
+ }
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ async frameBlocked(spec) {
347
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
348
+ const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
349
+ if (!targetId) return false;
350
+ const frame = await this.blockedFrameTarget(urls, targetId);
351
+ if (!frame) return false;
352
+ const attached = await outcome(
353
+ this.client.send('Target.attachToTarget', { targetId: frame, flatten: true })
354
+ );
355
+ if (attached.ok)
356
+ await outcome(
357
+ this.client.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
358
+ );
359
+ return !attached.ok;
360
+ }
361
+
362
+ async history(spec) {
363
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
364
+ const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
365
+ if (!targetId) return false;
366
+ const pre = await Promise.all(urls.map((url) => this.attemptCheck({ pre: url })));
367
+ if (!pre.some(Boolean)) return false;
368
+ const loaded = await Promise.all(urls.map((url) => this.attemptCheck({ loaded: url })));
369
+ const unloaded = urls.filter((_, index) => !loaded[index]);
370
+ if (!unloaded.length) return false;
371
+ const attached = await outcome(
372
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
373
+ );
374
+ if (!attached.ok) return false;
375
+ try {
376
+ const read = await outcome(
377
+ this.reader.send('Page.getNavigationHistory', {}, attached.result.sessionId)
378
+ );
379
+ return read.ok && read.result.entries.some((entry) => unloaded.includes(entry.url));
292
380
  } finally {
293
381
  await outcome(
294
382
  this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
@@ -339,50 +427,50 @@ class Execution {
339
427
  event.params.targetInfo?.targetId !== this.pageTarget &&
340
428
  event.params.targetInfo?.targetId !== this.canaryTarget &&
341
429
  event.params.sessionId !== this.sessions.page
342
- ) {
343
- const info = event.params.targetInfo;
344
- if (
345
- info?.browserContextId &&
346
- this.vars.context &&
347
- info.browserContextId !== this.vars.context
348
- )
349
- continue;
350
- this.sessions.child = event.params.sessionId;
351
- if (info?.targetId) this.targets.push(info.targetId);
352
- this.pending.push(
353
- (async () => {
354
- const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
355
- if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
356
- else {
357
- await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
358
- const start = this.client.events.length;
359
- await outcome(
360
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
361
- );
362
- await this.poll(
363
- () =>
364
- this.client.events
365
- .slice(start)
366
- .some(
367
- (e) =>
368
- e.method === 'Page.loadEventFired' &&
369
- e.sessionId === event.params.sessionId
370
- ),
371
- 1500
372
- );
373
- await this.execute(this.childSteps.slice(reload), event.params.sessionId);
374
- }
375
- await outcome(
376
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
377
- );
378
- })().catch((error) => this.errors.push(String(error.message ?? error)))
379
- );
380
- }
430
+ )
431
+ this.adopt(event);
381
432
  }
382
433
  await sleep(10);
383
434
  }
384
435
  }
385
436
 
437
+ adopt(event) {
438
+ const info = event.params.targetInfo;
439
+ const child = event.params.sessionId;
440
+ if (info?.browserContextId && this.vars.context && info.browserContextId !== this.vars.context)
441
+ return;
442
+ if (this.childWhere && !this.childMatches(info)) {
443
+ if (event.params.waitingForDebugger)
444
+ this.pending.push(outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child)));
445
+ return;
446
+ }
447
+ this.sessions.child = child;
448
+ if (info?.targetId) this.targets.push(info.targetId);
449
+ const started = (async () => {
450
+ const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
451
+ if (reload < 0) await this.execute(this.childSteps, child);
452
+ else {
453
+ await this.execute(this.childSteps.slice(0, reload), child);
454
+ const start = this.client.events.length;
455
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
456
+ await this.poll(
457
+ () =>
458
+ this.client.events
459
+ .slice(start)
460
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === child),
461
+ 1500
462
+ );
463
+ await this.execute(this.childSteps.slice(reload), child);
464
+ }
465
+ const resumed = this.client.events.length;
466
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
467
+ if (this.childSteps.some((step) => step.cdp === 'Page.enable'))
468
+ await this.poll(() => this.firstLoad(child, resumed), 5000);
469
+ })().catch((error) => this.errors.push(String(error.message ?? error)));
470
+ this.childRuns.set(child, started);
471
+ this.pending.push(started);
472
+ }
473
+
386
474
  async answer(event) {
387
475
  const p = event.params;
388
476
  const rule = this.answers.find(
@@ -410,23 +498,58 @@ class Execution {
410
498
  this.calls[`answer.${p.requestId}.${event.method}`] = out;
411
499
  }
412
500
 
501
+ firstLoad(session, from) {
502
+ const events = this.client.events.slice(from);
503
+ return events.some(
504
+ (event, index) =>
505
+ event.method === 'Page.frameNavigated' &&
506
+ event.sessionId === session &&
507
+ !event.params?.frame?.parentId &&
508
+ events
509
+ .slice(index + 1)
510
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === session)
511
+ );
512
+ }
513
+
514
+ childMatches(info) {
515
+ if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
516
+ return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
517
+ }
518
+
519
+ async childrenSettled() {
520
+ await Promise.all([...this.childRuns.values()]);
521
+ }
522
+
523
+ detached(session) {
524
+ return this.client.events
525
+ .slice(this.eventStart)
526
+ .some((e) => e.method === 'Target.detachedFromTarget' && e.params?.sessionId === session);
527
+ }
528
+
413
529
  async execute(list, childSession, phase = 'child') {
530
+ if (phase === 'trigger') await this.childrenSettled();
414
531
  for (const raw of list) {
415
532
  const step = expand(raw, this.vars);
416
533
  if (!supported(step.requires, this.options.capabilities) || step.text) continue;
534
+ if (!childSession && step.session === 'child') await this.childrenSettled();
417
535
  const session = childSession ?? this.session(step.session);
418
536
  const out = await this.operation(step, session, phase);
419
537
  if (!out) continue;
538
+ const gone = Boolean(childSession) && !out.ok && this.detached(childSession);
539
+ const refused =
540
+ !gone &&
541
+ (step.refusable
542
+ ? isRefusal(out, step)
543
+ : /refused/.test(String(out.error?.message ?? out.error)));
420
544
  this.trace.push({
421
545
  phase,
422
546
  declared: Boolean(step.refusable),
423
547
  session,
424
548
  out,
425
- refused: step.refusable
426
- ? isRefusal(out, step)
427
- : /refused/.test(String(out.error?.message ?? out.error)),
549
+ refused,
550
+ ...(gone ? { detached: true } : {}),
428
551
  });
429
- if (step.refusable) this.refused[step.refusal.name] = isRefusal(out, step);
552
+ if (step.refusable) this.refused[step.refusal.name] = refused;
430
553
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
431
554
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
432
555
  this.options.debug?.({ step, out, side: this.side });
@@ -436,6 +559,7 @@ class Execution {
436
559
  async operation(step, session, phase) {
437
560
  if (step.children) {
438
561
  this.childSteps = step.children;
562
+ this.childWhere = step.where;
439
563
  return;
440
564
  }
441
565
  if (step.answer) {
@@ -457,12 +581,13 @@ class Execution {
457
581
  }
458
582
 
459
583
  async settle(settle) {
584
+ if ((settle.dependsOn ?? []).some((name) => this.refused[name])) return;
460
585
  if (settle.variant) await sleep(this.delay);
461
586
  if (settle.title && !this.reader) {
462
587
  await sleep(settle.ms ?? 1500);
463
588
  return;
464
589
  }
465
- await this.poll(async () => {
590
+ const met = await this.poll(async () => {
466
591
  if (settle.beacon)
467
592
  return (
468
593
  (await this.requestCheck({ beacon: settle.beacon }, this.actionRequestStart)) &&
@@ -470,10 +595,20 @@ class Execution {
470
595
  (await this.requestCheck({ served: this.actionDestination }, this.actionRequestStart)))
471
596
  );
472
597
  if (settle.title) return this.check({ title: settle.title, session: settle.session });
473
- return this.client.events
474
- .slice(this.actionEventStart)
475
- .some((event) => event.method === 'Page.loadEventFired');
598
+ const events = this.client.events.slice(this.actionEventStart);
599
+ return events.some(
600
+ (event, index) =>
601
+ event.method === 'Page.frameNavigated' &&
602
+ !event.params?.frame?.parentId &&
603
+ events
604
+ .slice(index + 1)
605
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === event.sessionId)
606
+ );
476
607
  }, settle.ms ?? 1500);
608
+ if (settle.required && !met)
609
+ this.errors.push(
610
+ `settle: no ${settle.beacon ? `beacon from ${settle.beacon}` : settle.title ? `title ${settle.title}` : 'load'} within ${settle.ms ?? 1500}ms`
611
+ );
477
612
  }
478
613
 
479
614
  async guess(guess, session) {
@@ -502,18 +637,19 @@ class Execution {
502
637
  if (step.cdp === 'Target.setAutoAttach' && step.params?.autoAttach === false)
503
638
  this.cleanupEventStart = this.client.events.length;
504
639
  const params = { ...step.params };
505
- if (
506
- /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp) &&
507
- this.vars.context &&
508
- !params.browserContextId
509
- )
510
- params.browserContextId = this.vars.context;
511
640
  const session =
512
641
  step.session === 'page-or-browser' &&
513
642
  supported(['browserSession'], this.options.capabilities) &&
514
643
  /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp)
515
644
  ? undefined
516
645
  : sessionId;
646
+ if (
647
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp) &&
648
+ this.vars.context &&
649
+ !params.browserContextId &&
650
+ session === undefined
651
+ )
652
+ params.browserContextId = this.vars.context;
517
653
  const out = await outcome(this.client.send(step.cdp, params, session));
518
654
  if (step.cdp === 'Target.setAutoAttach' && out.ok) {
519
655
  if (params.autoAttach) this.attachSettings.set(session, true);
@@ -568,11 +704,11 @@ class Execution {
568
704
  if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
569
705
  const promise = this.evaluate(expression, session);
570
706
  if (step.background) {
571
- this.pending.push(
572
- promise.then((out) => {
573
- if (typeof step.as === 'string') this.calls[step.as] = out;
574
- })
575
- );
707
+ const done = promise.then((out) => {
708
+ if (typeof step.as === 'string') this.calls[step.as] = out;
709
+ });
710
+ this.pending.push(done);
711
+ if (typeof step.as === 'string') this.named.set(step.as, done);
576
712
  return { ok: true, result: { pending: true } };
577
713
  }
578
714
  const out = await promise;
@@ -584,9 +720,38 @@ class Execution {
584
720
  return out;
585
721
  }
586
722
 
723
+ async namedSettled(names) {
724
+ const waits = names.map((name) => this.named.get(name)).filter(Boolean);
725
+ if (!waits.length) return true;
726
+ let timer;
727
+ const expired = new Promise((resolve) => {
728
+ timer = setTimeout(() => resolve(false), this.options.namedResultMs ?? namedResultMs);
729
+ });
730
+ const done = await Promise.race([Promise.all(waits).then(() => true), expired]);
731
+ clearTimeout(timer);
732
+ if (!done) this.pending = this.pending.filter((work) => !waits.includes(work));
733
+ return done;
734
+ }
735
+
736
+ async ready(c) {
737
+ if (c.observer === 'unguarded' && !this.reader) return false;
738
+ return this.namedReady(c);
739
+ }
740
+
741
+ async namedReady(c) {
742
+ const names = [c.result, ...(c.compare ?? []), c.refused, c.reached]
743
+ .flat()
744
+ .filter((name) => typeof name === 'string' && this.named.has(name));
745
+ if (!names.length || (await this.namedSettled(names))) return true;
746
+ this.errors.push(
747
+ `${names.join(', ')} did not complete within ${this.options.namedResultMs ?? namedResultMs}ms`
748
+ );
749
+ return false;
750
+ }
751
+
587
752
  async check(check) {
588
753
  const c = expand(check, this.vars);
589
- if (c.observer === 'unguarded' && !this.reader) return false;
754
+ if (!(await this.ready(c))) return false;
590
755
  const events = this.client.events.slice(this.eventStart);
591
756
  const evidence = await this.evidence(c);
592
757
  if (evidence !== undefined) return evidence;
@@ -671,6 +836,8 @@ class Execution {
671
836
  );
672
837
  if (c.state) return this.readState(c.state);
673
838
  if (c.blocked) return this.blocked(c.blocked, c.observer);
839
+ if (c.history) return this.history(c.history);
840
+ if (c.frameBlocked) return this.frameBlocked(c.frameBlocked);
674
841
  if (c.pre || c.loaded) return this.attemptCheck(c);
675
842
  }
676
843
 
@@ -786,6 +953,8 @@ class Execution {
786
953
  if (!supported(observation.requires, this.options.capabilities)) continue;
787
954
  if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
788
955
  observed[name] = await this.check(observation.check);
956
+ if (observation.eitherWhen && this.side === 'guarded')
957
+ observed[`${name}.either`] = await this.check(observation.eitherWhen);
789
958
  }
790
959
  this.options.debug?.({
791
960
  events: this.client.events.slice(this.eventStart),
@@ -826,7 +995,9 @@ const observerRequired = (check) => {
826
995
  const needsObserver = (vector) =>
827
996
  vector.control.via === 'unguarded' ||
828
997
  observerRequired(vector.liveCheck) ||
829
- Object.values(vector.observe ?? {}).some((observation) => observerRequired(observation.check));
998
+ Object.values(vector.observe ?? {}).some(
999
+ (observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)
1000
+ );
830
1001
 
831
1002
  const inactive = (vector, target, capabilities) =>
832
1003
  vector.skip?.target === target ||
@@ -834,16 +1005,87 @@ const inactive = (vector, target, capabilities) =>
834
1005
  !supported(vector.requires, capabilities) ||
835
1006
  (vector.scope !== 'any' && vector.scope !== capabilities.scope);
836
1007
 
837
- function unavailableOrigin(vector, target, targets = {}) {
838
- const defaults = { plain: 'ordinary', cross: 'client', log: 'ordinary', own: 'client' };
839
- const references = [target, 'plain', vector.control.origin];
840
- for (const match of q([vector.setup, vector.trigger, vector.observe, vector.liveCheck]).matchAll(
841
- /\{([A-Za-z][A-Za-z-]*)\}/g
842
- ))
843
- references.push(match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]);
844
- return references
845
- .map((name) => (targets[name] === undefined ? (defaults[name] ?? name) : name))
846
- .find((name) => targets[name] === null);
1008
+ const allSteps = (list) => list.flatMap((step) => [step, ...allSteps(step.children ?? [])]);
1009
+ const browserContextStep = (vector, capabilities) =>
1010
+ allSteps([...vector.setup, ...vector.trigger]).some(
1011
+ (step) =>
1012
+ step.params?.browserContextId !== undefined ||
1013
+ (step.session === 'page-or-browser' &&
1014
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp ?? '') &&
1015
+ supported(['browserContexts'], capabilities))
1016
+ );
1017
+
1018
+ const originDefaults = { plain: 'ordinary', cross: 'client', log: 'ordinary', own: 'client' };
1019
+ const knownOrigins = [
1020
+ 'ordinary',
1021
+ 'ordinary-same-site',
1022
+ 'seven',
1023
+ 'vault',
1024
+ 'vault-x',
1025
+ 'apex',
1026
+ 'own',
1027
+ 'client',
1028
+ 'plain',
1029
+ 'cross',
1030
+ 'log',
1031
+ ];
1032
+
1033
+ function nullOrigins(value, targets) {
1034
+ return [...q(value).matchAll(/\{([A-Za-z][A-Za-z-]*)\}/g)]
1035
+ .map((match) => (match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]))
1036
+ .map((name) => (targets[name] === undefined ? (originDefaults[name] ?? name) : name))
1037
+ .filter((name) => knownOrigins.includes(name) && (targets[name] ?? null) === null);
1038
+ }
1039
+
1040
+ function prune(check, targets) {
1041
+ if (!check || typeof check !== 'object') return { check };
1042
+ if (check.any) {
1043
+ const parts = check.any.map((item) => prune(item, targets));
1044
+ const kept = parts.filter((part) => !part.missing).map((part) => part.check);
1045
+ return kept.length ? { check: { ...check, any: kept } } : { missing: parts[0]?.missing };
1046
+ }
1047
+ if (check.all || check.not) {
1048
+ const parts = (check.all ?? [check.not]).map((item) => prune(item, targets));
1049
+ const missing = parts.find((part) => part.missing)?.missing;
1050
+ if (missing) return { missing };
1051
+ return {
1052
+ check: check.all
1053
+ ? { ...check, all: parts.map((part) => part.check) }
1054
+ : { ...check, not: parts[0].check },
1055
+ };
1056
+ }
1057
+ const missing = nullOrigins(check, targets)[0];
1058
+ return missing ? { missing } : { check };
1059
+ }
1060
+
1061
+ function available(vector, target, targets = {}) {
1062
+ const fixed = nullOrigins(
1063
+ [target, '{plain}', vector.control.origin, vector.setup, vector.trigger].map((value) =>
1064
+ typeof value === 'string' && !value.startsWith('{') ? `{${value}}` : value
1065
+ ),
1066
+ targets
1067
+ )[0];
1068
+ if (fixed) return { missing: fixed };
1069
+ const live = prune(vector.liveCheck, targets);
1070
+ if (live.missing) return { missing: live.missing };
1071
+ const observe = {};
1072
+ for (const [name, observation] of Object.entries(vector.observe ?? {})) {
1073
+ const pruned = prune(observation.check, targets);
1074
+ if (pruned.missing) return { missing: pruned.missing };
1075
+ const either = observation.eitherWhen ? prune(observation.eitherWhen, targets) : undefined;
1076
+ observe[name] = {
1077
+ ...observation,
1078
+ check: pruned.check,
1079
+ ...(observation.eitherWhen ? { eitherWhen: either.missing ? undefined : either.check } : {}),
1080
+ };
1081
+ }
1082
+ return {
1083
+ vector: {
1084
+ ...vector,
1085
+ ...(vector.liveCheck ? { liveCheck: live.check } : {}),
1086
+ ...(vector.observe ? { observe } : {}),
1087
+ },
1088
+ };
847
1089
  }
848
1090
 
849
1091
  function variables(targets, target, side, slot, index) {
@@ -865,7 +1107,9 @@ function variables(targets, target, side, slot, index) {
865
1107
  }
866
1108
 
867
1109
  async function executeRow(job, opts, clients, set, slot, index) {
868
- const { vector, target, variant } = job;
1110
+ const { target, variant } = job;
1111
+ const scoped = available(job.vector, target, opts.targets);
1112
+ const vector = scoped.vector ?? job.vector;
869
1113
  const base = {
870
1114
  id: vector.id,
871
1115
  target,
@@ -874,7 +1118,7 @@ async function executeRow(job, opts, clients, set, slot, index) {
874
1118
  via: vector.control.via,
875
1119
  transportVariant: clients.transportVariant === true,
876
1120
  };
877
- const unavailable = unavailableOrigin(vector, target, opts.targets);
1121
+ const unavailable = scoped.missing;
878
1122
  if (unavailable)
879
1123
  return {
880
1124
  ...base,
@@ -891,8 +1135,27 @@ async function executeRow(job, opts, clients, set, slot, index) {
891
1135
  live: 'not-run',
892
1136
  appliesTo: vector.appliesTo ?? [],
893
1137
  };
894
- if (inactive(vector, target, opts.capabilities))
895
- return { ...base, control: 'skip', guarded: 'skip', live: 'skip', reason: 'not-applicable' };
1138
+ if (inactive(vector, target, opts.capabilities)) {
1139
+ const missing = (vector.requires ?? []).find((cap) => !supported([cap], opts.capabilities));
1140
+ return {
1141
+ ...base,
1142
+ control: 'skip',
1143
+ guarded: 'skip',
1144
+ live: 'skip',
1145
+ reason: missing ? `not-supported:${missing}` : 'not-applicable',
1146
+ };
1147
+ }
1148
+ if (
1149
+ !supported(['browserSession'], opts.capabilities) &&
1150
+ browserContextStep(vector, opts.capabilities)
1151
+ )
1152
+ return {
1153
+ ...base,
1154
+ control: 'skip',
1155
+ guarded: 'skip',
1156
+ live: 'skip',
1157
+ reason: 'not-supported:browserSession',
1158
+ };
896
1159
  if (needsObserver(vector) && !clients.unguarded)
897
1160
  return {
898
1161
  ...base,
@@ -954,9 +1217,10 @@ const invalidBlocked = (check) =>
954
1217
  function validateBlocked(vectors) {
955
1218
  for (const vector of vectors)
956
1219
  if (
957
- [vector.liveCheck, ...Object.values(vector.observe ?? {}).map((o) => o.check)].some(
958
- invalidBlocked
959
- )
1220
+ [
1221
+ vector.liveCheck,
1222
+ ...Object.values(vector.observe ?? {}).flatMap((o) => [o.check, o.eitherWhen]),
1223
+ ].some(invalidBlocked)
960
1224
  )
961
1225
  throw new TypeError(`${vector.id}: blocked needs exactly one url or nonempty anyOf`);
962
1226
  }
@@ -1060,10 +1324,22 @@ export function summary({ set, results, guard = 'reference', sha = 'unknown' })
1060
1324
  const observerSkipped = results.filter(
1061
1325
  (row) => row.reason === 'not-supported:unguarded-observer'
1062
1326
  ).length;
1327
+ const skips = {};
1328
+ for (const row of results)
1329
+ if (row.control === 'skip')
1330
+ skips[row.reason ?? 'unknown'] = (skips[row.reason ?? 'unknown'] ?? 0) + 1;
1331
+ const skipped = Object.values(skips).reduce((sum, count) => sum + count, 0);
1332
+ const line = observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line;
1063
1333
  return {
1064
1334
  ...value,
1065
1335
  pass: value.pass && observerSkipped === 0,
1066
- line: observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line,
1336
+ line: skipped
1337
+ ? `${line} skips ${skipped} (${Object.entries(skips)
1338
+ .sort(([a], [b]) => a.localeCompare(b))
1339
+ .map(([reason, count]) => `${reason}=${count}`)
1340
+ .join(', ')})`
1341
+ : line,
1342
+ skips,
1067
1343
  observerSkipped,
1068
1344
  counts: Object.fromEntries(
1069
1345
  ['control', 'guarded', 'live'].map((side) => [