@ai-ecoverse/slicc-browsers 1.21.1 → 1.22.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/guard-runner.mjs CHANGED
@@ -31,12 +31,48 @@ import {
31
31
  } from './guard-driver.mjs';
32
32
  import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
33
33
  import { protectedErrorPage } from './protected-error-page.mjs';
34
+ import {
35
+ leftoverPersistentWorker,
36
+ persistentWorkerTypes,
37
+ probeHarnessIncomplete,
38
+ probeWorkerSessions,
39
+ protectedWorker,
40
+ releasePersistentWorkerSessions,
41
+ workerTypeForKind,
42
+ } from './protected-worker.mjs';
34
43
  import { redirectSecret } from './redirect-secret.mjs';
35
44
  import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
36
45
 
46
+ export { leftoverPersistentWorker, releasePersistentWorkerSessions, workerTypeForKind };
47
+
37
48
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38
49
  const namedResultMs = 10000;
39
50
  const observeMs = 5000;
51
+
52
+ function autoAttachSession(events, start, targetId) {
53
+ return events
54
+ .slice(start)
55
+ .filter(
56
+ (event) =>
57
+ event.method === 'Target.attachedToTarget' &&
58
+ event.params?.targetInfo?.targetId === targetId &&
59
+ event.params?.sessionId
60
+ )
61
+ .at(-1)?.params?.sessionId;
62
+ }
63
+
64
+ export function reusableAttachSession(step, out, params, events, start) {
65
+ if (!step?.reuseSession || step.cdp !== 'Target.attachToTarget' || !params?.targetId)
66
+ return undefined;
67
+ if (out?.ok && out.result?.sessionId) return undefined;
68
+ const message = String(out?.error?.message ?? '');
69
+ if (/refused|not allowed/i.test(message)) return undefined;
70
+ const already =
71
+ (out?.ok === true && !out.result?.sessionId) ||
72
+ /already attached|attached already|Target is attaching or attached/i.test(message);
73
+ if (!already) return undefined;
74
+ return autoAttachSession(events, start, params.targetId);
75
+ }
40
76
  const browserUiAttempts = 3;
41
77
  const browserUiMs = 5000;
42
78
  const q = JSON.stringify;
@@ -50,6 +86,13 @@ const supported = (requires, capabilities) =>
50
86
  (requires ?? []).every((c) =>
51
87
  Array.isArray(capabilities) ? capabilities.includes(c) : capabilities[c] === true
52
88
  );
89
+ const declared = (capability, capabilities) =>
90
+ Array.isArray(capabilities)
91
+ ? capabilities.includes(capability)
92
+ : capabilities[capability] === true;
93
+ const gatedOut = (observation, capabilities) =>
94
+ !supported(observation?.requires, capabilities) ||
95
+ (observation?.unless ?? []).some((capability) => declared(capability, capabilities));
53
96
  const matches = (value, predicate) => {
54
97
  if (predicate === null || typeof predicate !== 'object') return value === predicate;
55
98
  return Object.entries(predicate).every(([key, want]) => {
@@ -164,12 +207,71 @@ class Execution {
164
207
  this.refusalScale = options.refusalScale ?? 1;
165
208
  this.childRuns = new Map();
166
209
  this.named = new Map();
210
+ this.eventMarks = new Map();
167
211
  this.processed = new Set();
168
212
  this.eventStart = this.client.events.length;
169
213
  this.actionEventStart = this.eventStart;
170
214
  this.requestStart = undefined;
171
215
  this.trace = [];
172
216
  this.stopped = false;
217
+ this.cdpTrace = [];
218
+ this.cdpSessionTypes = new Map();
219
+ this.cdpTraceInstalled = false;
220
+ }
221
+
222
+ note(message) {
223
+ if (this.notes.length >= 80) this.notes.shift();
224
+ Array.prototype.push.call(this.notes, message);
225
+ }
226
+
227
+ installClientTrace() {
228
+ if (!this.spawnWorkerKind() || this.cdpTraceInstalled) return;
229
+ this.cdpTraceInstalled = true;
230
+ const prev = this.client.__sliccOnSend;
231
+ this.client.__sliccBoundGetFrameTree = true;
232
+ this.client.__sliccOnSend = (entry) => {
233
+ prev?.(entry);
234
+ this.recordCdpTrace(entry);
235
+ };
236
+ }
237
+
238
+ recordCdpTrace(entry) {
239
+ let type = entry.sessionId ? this.cdpSessionTypes.get(entry.sessionId) : 'browser';
240
+ if (entry.sessionId && type === undefined) {
241
+ type = '';
242
+ const events = this.client.events;
243
+ for (let i = events.length - 1; i >= 0; i--) {
244
+ const event = events[i];
245
+ if (
246
+ event.method === 'Target.attachedToTarget' &&
247
+ event.params?.sessionId === entry.sessionId
248
+ ) {
249
+ type = event.params.targetInfo?.type ?? '';
250
+ break;
251
+ }
252
+ }
253
+ this.cdpSessionTypes.set(entry.sessionId, type);
254
+ }
255
+ if (this.cdpTrace.length >= 40) this.cdpTrace.shift();
256
+ this.cdpTrace.push({
257
+ method: entry.method,
258
+ sessionId: entry.sessionId ?? '',
259
+ type: type || (entry.sessionId ? '?' : 'browser'),
260
+ ms: entry.ms,
261
+ status: String(entry.status).slice(0, 200),
262
+ });
263
+ }
264
+
265
+ noteClientTrace() {
266
+ if (!this.cdpTrace.length) return;
267
+ this.note(
268
+ `client cdp: ${this.cdpTrace
269
+ .map(
270
+ (call) =>
271
+ `${call.method}@${call.sessionId || 'browser'}:${call.type || '?'} ${call.ms}ms ${call.status}`
272
+ )
273
+ .join(' | ')}`
274
+ );
173
275
  }
174
276
 
175
277
  actor() {
@@ -192,8 +294,7 @@ class Execution {
192
294
  );
193
295
  return read.ok && read.result.targetInfo?.attached === false;
194
296
  }, 5000);
195
- if (!released)
196
- this.notes.push(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
297
+ if (!released) this.note(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
197
298
  const { sessionId } = await this.reader.send('Target.attachToTarget', {
198
299
  targetId: this.pageTarget,
199
300
  flatten: true,
@@ -400,53 +501,55 @@ class Execution {
400
501
  );
401
502
  }
402
503
 
504
+ async readerFrameTree(targetId, label) {
505
+ const attached = await outcome(
506
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
507
+ );
508
+ if (!attached.ok) return { ok: false, sessionId: undefined, frameTree: undefined };
509
+ const sessionId = attached.result.sessionId;
510
+ await outcome(this.reader.send('Runtime.runIfWaitingForDebugger', {}, sessionId));
511
+ const read = await outcome(this.reader.send('Page.getFrameTree', {}, sessionId));
512
+ if (!read.ok) {
513
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
514
+ throw new Error(
515
+ `Page.getFrameTree timed out on ${label} target ${targetId} session ${sessionId}: ${read.error?.message ?? 'unknown'}`
516
+ );
517
+ }
518
+ return { ok: true, sessionId, frameTree: read.result.frameTree };
519
+ }
520
+
403
521
  async blocked(spec, observer) {
404
522
  const urls = (spec.anyOf ?? [spec.url]).map(address);
405
523
  if (observer === 'client') return this.clientBlocked(spec, urls);
406
524
  const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
407
525
  if (!targetId) return false;
408
- const attached = await outcome(
409
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
410
- );
526
+ const attached = await this.readerFrameTree(targetId, 'blocked page');
411
527
  if (!attached.ok) return false;
412
528
  try {
413
- const read = await outcome(
414
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
415
- );
416
529
  const blockedFrame = (frame) =>
417
530
  Boolean(
418
531
  frame && urls.includes(frame.unreachableUrl) && frame.url.startsWith('chrome-error://')
419
532
  );
420
533
  const visit = (tree) =>
421
534
  Boolean(tree && (blockedFrame(tree.frame) || (tree.childFrames ?? []).some(visit)));
422
- if (!read.ok) return false;
423
535
  if (spec.frame) {
424
- if ((read.result.frameTree.childFrames ?? []).some(visit)) return true;
536
+ if ((attached.frameTree.childFrames ?? []).some(visit)) return true;
425
537
  return Boolean(await this.blockedFrameTarget(urls, targetId));
426
538
  }
427
- return blockedFrame(read.result.frameTree.frame);
539
+ return blockedFrame(attached.frameTree.frame);
428
540
  } finally {
429
- await outcome(
430
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
431
- );
541
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
432
542
  }
433
543
  }
434
544
 
435
545
  async frameIds(targetId) {
436
- const attached = await outcome(
437
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
438
- );
546
+ const attached = await this.readerFrameTree(targetId, 'frameIds page');
439
547
  if (!attached.ok) return [];
440
548
  try {
441
- const read = await outcome(
442
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
443
- );
444
549
  const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
445
- return read.ok ? ids(read.result.frameTree) : [];
550
+ return ids(attached.frameTree);
446
551
  } finally {
447
- await outcome(
448
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
449
- );
552
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
450
553
  }
451
554
  }
452
555
 
@@ -456,21 +559,16 @@ class Execution {
456
559
  if (!listed.ok) return undefined;
457
560
  for (const info of listed.result.targetInfos) {
458
561
  if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
459
- const attached = await outcome(
460
- this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
461
- );
462
- if (!attached.ok) continue;
562
+ let sessionId;
463
563
  try {
464
- const read = await outcome(
465
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
466
- );
467
- const frame = read.result?.frameTree?.frame;
564
+ const attached = await this.readerFrameTree(info.targetId, `iframe ${info.type}`);
565
+ if (!attached.ok) continue;
566
+ sessionId = attached.sessionId;
567
+ const frame = attached.frameTree?.frame;
468
568
  if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
469
569
  return info.targetId;
470
570
  } finally {
471
- await outcome(
472
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
473
- );
571
+ if (sessionId) await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
474
572
  }
475
573
  }
476
574
  return undefined;
@@ -524,7 +622,7 @@ class Execution {
524
622
  } catch (error) {
525
623
  const message = String(error?.message ?? error);
526
624
  if (!/timed? ?out/i.test(message)) throw error;
527
- this.notes.push(`Target.createBrowserContext retried once after: ${message}`);
625
+ this.note(`Target.createBrowserContext retried once after: ${message}`);
528
626
  return (await client.send('Target.createBrowserContext', {})).browserContextId;
529
627
  }
530
628
  }
@@ -586,17 +684,38 @@ class Execution {
586
684
  }
587
685
  }
588
686
 
687
+ async resumeWaiting(from = this.eventStart) {
688
+ for (const event of this.client.events.slice(from)) {
689
+ if (
690
+ event.method === 'Target.attachedToTarget' &&
691
+ event.params?.waitingForDebugger &&
692
+ event.params?.sessionId
693
+ )
694
+ await outcome(
695
+ this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
696
+ );
697
+ }
698
+ }
699
+
589
700
  adopt(event) {
590
701
  const info = event.params.targetInfo;
591
702
  const child = event.params.sessionId;
592
703
  if (info?.type === 'browser_ui') return;
593
704
  const paused = event.params.waitingForDebugger || this.tabSessions.has(event.sessionId);
705
+ const workerRow = Boolean(this.spawnWorkerKind());
594
706
  if (this.expectWhere && !this.childWhere) {
595
707
  this.deferred.push(event);
596
708
  return;
597
709
  }
598
- if (info?.browserContextId && this.vars.context && info.browserContextId !== this.vars.context)
710
+ if (
711
+ info?.browserContextId &&
712
+ this.vars.context &&
713
+ info.browserContextId !== this.vars.context
714
+ ) {
715
+ if (paused && workerRow)
716
+ this.pending.push(outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child)));
599
717
  return;
718
+ }
600
719
  const matched = !this.childWhere || this.childMatches(info);
601
720
  const isTab = info?.type === 'tab';
602
721
  if (!matched && !isTab) {
@@ -613,6 +732,27 @@ class Execution {
613
732
  ? this.childSteps
614
733
  : this.childSteps.filter((step) => step.cdp === 'Target.setAutoAttach');
615
734
  const started = (async () => {
735
+ if (workerRow) {
736
+ if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
737
+ const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
738
+ if (reload < 0) await this.execute(nested, child);
739
+ else {
740
+ await this.execute(nested.slice(0, reload), child);
741
+ const start = this.client.events.length;
742
+ await this.poll(
743
+ () =>
744
+ this.client.events
745
+ .slice(start)
746
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === child),
747
+ 1500
748
+ );
749
+ await this.execute(nested.slice(reload), child);
750
+ }
751
+ const resumed = this.client.events.length;
752
+ if (nested.some((step) => step.cdp === 'Page.enable'))
753
+ await this.poll(() => this.firstLoad(child, resumed), 5000);
754
+ return;
755
+ }
616
756
  const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
617
757
  if (reload < 0) await this.execute(nested, child);
618
758
  else {
@@ -632,7 +772,13 @@ class Execution {
632
772
  if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
633
773
  if (nested.some((step) => step.cdp === 'Page.enable'))
634
774
  await this.poll(() => this.firstLoad(child, resumed), 5000);
635
- })().catch((error) => this.errors.push(String(error.message ?? error)));
775
+ })().catch((error) =>
776
+ this.errors.push(
777
+ workerRow
778
+ ? `adopt ${info?.type ?? 'target'} ${child}: ${String(error.message ?? error)}`
779
+ : String(error.message ?? error)
780
+ )
781
+ );
636
782
  this.childRuns.set(child, started);
637
783
  this.pending.push(started);
638
784
  }
@@ -697,7 +843,11 @@ class Execution {
697
843
  }
698
844
 
699
845
  childMatches(info) {
700
- if (this.childWhere.type && info?.type !== this.childWhere.type) return false;
846
+ if (this.childWhere.types !== undefined) {
847
+ if (!this.childWhere.types.includes(info?.type)) return false;
848
+ } else if (this.childWhere.type !== undefined) {
849
+ if (this.childWhere.type !== info?.type) return false;
850
+ }
701
851
  if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
702
852
  if (this.childWhere.urlPrefix !== undefined)
703
853
  return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
@@ -784,7 +934,7 @@ class Execution {
784
934
  Boolean(childSession) && !out.ok && this.detached(childSession)
785
935
  );
786
936
  if ((step.isolatedWorld || step.observer) && !out.ok)
787
- this.notes.push(
937
+ this.note(
788
938
  `${step.isolatedWorld ? `isolatedWorld ${step.isolatedWorld}` : `observer ${step.observer}`}: ${out.error?.message ?? 'failed'}`
789
939
  );
790
940
  this.options.debug?.({ step, out, side: this.side });
@@ -819,7 +969,7 @@ class Execution {
819
969
  return Boolean(next);
820
970
  }, browserUiMs);
821
971
  if (!next) return undefined;
822
- this.notes.push(
972
+ this.note(
823
973
  `{browserUi} ${vanished} was gone when ${step.cdp} reached it; retried with ${next.targetId}`
824
974
  );
825
975
  this.vars.browserUi = next.targetId;
@@ -850,48 +1000,9 @@ class Execution {
850
1000
  return;
851
1001
  }
852
1002
  if (step.client) return outcome(this.exit());
853
- if (step.redirectSecret) {
854
- this.internalSessions.add(session);
855
- try {
856
- return await outcome(
857
- redirectSecret({
858
- client: this.client,
859
- reader: this.reader,
860
- session,
861
- targetId: this.pageTarget,
862
- ordinary: this.vars.ordinary,
863
- tag: this.vars.tag,
864
- fixture: this.options.fixture,
865
- mode: step.redirectSecret,
866
- side: this.side,
867
- settleScale: this.scale,
868
- controls: (this.clients.redirectSecretControls ??= {}),
869
- internalSessions: this.internalSessions,
870
- })
871
- );
872
- } finally {
873
- this.internalSessions.delete(session);
874
- }
875
- }
876
- if (step.errorPage)
877
- return outcome(
878
- protectedErrorPage({
879
- client: this.client,
880
- reader: this.reader,
881
- session,
882
- targetId: this.pageTarget,
883
- ordinary: this.vars.ordinary,
884
- protectedOrigin: this.vars.seven,
885
- tag: this.vars.tag,
886
- internalSessions: this.internalSessions,
887
- mode: step.errorPage,
888
- side: this.side,
889
- secret: step.secret,
890
- discovery: supported(['discovery'], this.options.capabilities),
891
- notes: this.notes,
892
- budget: this.bound(5000),
893
- })
894
- );
1003
+ if (step.redirectSecret || step.errorPage) return this.originProbe(step, session);
1004
+ if (step.spawnWorker) return this.spawnWorker(step);
1005
+ if (step.probeSessions) return this.probeSessions(step);
895
1006
  if (step.bfcacheRestore)
896
1007
  return outcome(
897
1008
  bfcacheRestore({
@@ -1002,12 +1113,10 @@ class Execution {
1002
1113
 
1003
1114
  async isolatedWorld(step) {
1004
1115
  const targetId = step.session === 'child' ? this.targets.at(-1) : this.pageTarget;
1005
- const { sessionId } = await this.reader.send('Target.attachToTarget', {
1006
- targetId,
1007
- flatten: true,
1008
- });
1116
+ const attached = await this.readerFrameTree(targetId, 'isolatedWorld page');
1117
+ if (!attached.ok) throw new Error('isolatedWorld attach failed');
1118
+ const { sessionId, frameTree } = attached;
1009
1119
  try {
1010
- const { frameTree } = await this.reader.send('Page.getFrameTree', {}, sessionId);
1011
1120
  const { executionContextId } = await this.reader.send(
1012
1121
  'Page.createIsolatedWorld',
1013
1122
  { frameId: frameTree.frame.id, worldName: step.isolatedWorld },
@@ -1034,6 +1143,224 @@ class Execution {
1034
1143
  }
1035
1144
  }
1036
1145
 
1146
+ async originProbe(step, session) {
1147
+ if (step.redirectSecret) {
1148
+ this.internalSessions.add(session);
1149
+ try {
1150
+ return await outcome(
1151
+ redirectSecret({
1152
+ client: this.client,
1153
+ reader: this.reader,
1154
+ session,
1155
+ targetId: this.pageTarget,
1156
+ ordinary: this.vars.ordinary,
1157
+ tag: this.vars.tag,
1158
+ fixture: this.options.fixture,
1159
+ mode: step.redirectSecret,
1160
+ side: this.side,
1161
+ settleScale: this.scale,
1162
+ controls: (this.clients.redirectSecretControls ??= {}),
1163
+ internalSessions: this.internalSessions,
1164
+ })
1165
+ );
1166
+ } finally {
1167
+ this.internalSessions.delete(session);
1168
+ }
1169
+ }
1170
+ return outcome(
1171
+ protectedErrorPage({
1172
+ client: this.client,
1173
+ reader: this.reader,
1174
+ session,
1175
+ targetId: this.pageTarget,
1176
+ ordinary: this.vars.ordinary,
1177
+ protectedOrigin: this.vars.seven,
1178
+ tag: this.vars.tag,
1179
+ internalSessions: this.internalSessions,
1180
+ mode: step.errorPage,
1181
+ side: this.side,
1182
+ secret: step.secret,
1183
+ discovery: supported(['discovery'], this.options.capabilities),
1184
+ notes: this.notes,
1185
+ budget: this.bound(5000),
1186
+ })
1187
+ );
1188
+ }
1189
+
1190
+ spawnWorkerKind() {
1191
+ return allSteps([...(this.vector.setup ?? []), ...(this.vector.trigger ?? [])]).find(
1192
+ (step) => step.spawnWorker
1193
+ )?.spawnWorker;
1194
+ }
1195
+
1196
+ async ensurePriorWorkersGone(kind) {
1197
+ if (this.side !== 'guarded') return;
1198
+ const type = workerTypeForKind(kind);
1199
+ if (!persistentWorkerTypes.has(type)) return;
1200
+ const prior = this.clients.priorWorker;
1201
+ const leftover = await leftoverPersistentWorker(this.reader ?? this.client, {
1202
+ type,
1203
+ ...(prior?.url || prior?.targetId ? { url: prior?.url, targetId: prior?.targetId } : {}),
1204
+ });
1205
+ if (leftover)
1206
+ throw new Error(
1207
+ `leftover ${type} still present before guarded spawn: ${leftover.url ?? leftover.targetId}`
1208
+ );
1209
+ }
1210
+
1211
+ async spawnWorker(step) {
1212
+ if (step.as) this.eventMarks.set(step.as, this.client.events.length);
1213
+ const pageUrl = expand(step.url ?? '{target}/doc?n=host-{tag}', this.vars);
1214
+ const hostUrl = step.frame ? expand(step.frame, this.vars) : undefined;
1215
+ await this.ensurePriorWorkersGone(step.spawnWorker);
1216
+ const out = await outcome(
1217
+ protectedWorker({
1218
+ reader: this.reader,
1219
+ kind: step.spawnWorker,
1220
+ pageUrl,
1221
+ hostUrl,
1222
+ tag: this.vars.tag,
1223
+ context: this.vars.context,
1224
+ notes: this.notes,
1225
+ budget: this.bound(5000),
1226
+ })
1227
+ );
1228
+ if (out.ok && out.result?.browserContextId) {
1229
+ this.contexts.push(out.result.browserContextId);
1230
+ this.vars.context = out.result.browserContextId;
1231
+ }
1232
+ if (out.ok && out.result?.pageTargetId) this.targets.push(out.result.pageTargetId);
1233
+ if (out.ok && out.result?.workerTargetId) {
1234
+ this.vars.workerTarget = out.result.workerTargetId;
1235
+ this.targets.push(out.result.workerTargetId);
1236
+ }
1237
+ if (out.ok && out.result?.workerUrl && this.side === 'control')
1238
+ this.clients.priorWorker = {
1239
+ type: out.result.type,
1240
+ url: out.result.workerUrl,
1241
+ targetId: out.result.workerTargetId,
1242
+ };
1243
+ if (!out.ok) this.errors.push(`spawnWorker: ${out.error?.message ?? 'probe failed'}`);
1244
+ if (out.ok && out.result?.incomplete)
1245
+ this.incomplete.push(`spawnWorker: ${out.result.incomplete}`);
1246
+ return out;
1247
+ }
1248
+
1249
+ probeSessionIds(step) {
1250
+ const mark = this.eventMarks.get(step.probeSessions.since) ?? this.eventStart;
1251
+ const sessions = [];
1252
+ for (const event of this.client.events.slice(mark)) {
1253
+ if (event.method === 'Target.attachedToTarget' && event.params?.sessionId)
1254
+ sessions.push(event.params.sessionId);
1255
+ }
1256
+ for (const name of step.probeSessions.attach ?? ['attach']) {
1257
+ const sessionId = this.calls[name]?.result?.sessionId;
1258
+ if (sessionId) sessions.push(sessionId);
1259
+ }
1260
+ return sessions;
1261
+ }
1262
+
1263
+ async probeSessions(step) {
1264
+ const out = await outcome(
1265
+ probeWorkerSessions({
1266
+ client: this.client,
1267
+ sessions: this.probeSessionIds(step),
1268
+ path: expand(step.probeSessions.path, this.vars),
1269
+ tag: this.vars.tag,
1270
+ detached: (sessionId) => this.detached(sessionId),
1271
+ })
1272
+ );
1273
+ this.probedSessionIds ??= new Set();
1274
+ for (const sessionId of out.result?.probedIds ?? []) this.probedSessionIds.add(sessionId);
1275
+ if (!out.ok) {
1276
+ this.errors.push(`probeSessions: ${out.error?.message ?? 'probe failed'}`);
1277
+ return out;
1278
+ }
1279
+ if (out.result?.incomplete) {
1280
+ const reason = probeHarnessIncomplete(this.side, out.result.incomplete);
1281
+ if (reason) this.incomplete.push(`probeSessions: ${reason}`);
1282
+ const { incomplete: _drop, ...result } = out.result;
1283
+ return { ...out, result };
1284
+ }
1285
+ return out;
1286
+ }
1287
+
1288
+ probeStep() {
1289
+ return [...(this.vector.setup ?? []), ...(this.vector.trigger ?? [])].find(
1290
+ (entry) => entry.probeSessions
1291
+ );
1292
+ }
1293
+
1294
+ async probeFreshSessions() {
1295
+ const step = this.probeStep();
1296
+ if (!step?.probeSessions) return false;
1297
+ const expanded = expand(step, this.vars);
1298
+ this.probedSessionIds ??= new Set();
1299
+ const fresh = this.probeSessionIds(expanded).filter(
1300
+ (sessionId) => sessionId && !this.probedSessionIds.has(sessionId)
1301
+ );
1302
+ if (!fresh.length) return this.calls.probe?.result?.reachable === true;
1303
+ const out = await probeWorkerSessions({
1304
+ client: this.client,
1305
+ sessions: fresh,
1306
+ path: expand(expanded.probeSessions.path, this.vars),
1307
+ tag: this.vars.tag,
1308
+ detached: (sessionId) => this.detached(sessionId),
1309
+ });
1310
+ for (const sessionId of out.probedIds ?? []) this.probedSessionIds.add(sessionId);
1311
+ const sessions = [
1312
+ ...new Set([...(this.calls.probe?.result?.sessions ?? []), ...(out.probedIds ?? fresh)]),
1313
+ ];
1314
+ if (out.incomplete) {
1315
+ this.calls.probe = {
1316
+ ok: true,
1317
+ result: { ...(this.calls.probe?.result ?? {}), ...out, sessions },
1318
+ };
1319
+ const reason = probeHarnessIncomplete(this.side, out.incomplete);
1320
+ if (reason) {
1321
+ this.incomplete.push(`probeSessions: ${reason}`);
1322
+ return 'incomplete';
1323
+ }
1324
+ }
1325
+ const reachable = out.reachable || this.calls.probe?.result?.reachable === true;
1326
+ this.calls.probe = {
1327
+ ok: true,
1328
+ result: {
1329
+ ...(this.calls.probe?.result ?? {}),
1330
+ reachable,
1331
+ probed: (this.calls.probe?.result?.probed ?? 0) + (out.probed ?? 0),
1332
+ sessions,
1333
+ },
1334
+ };
1335
+ return reachable;
1336
+ }
1337
+
1338
+ async observeProbeReachable(want, check) {
1339
+ if (want !== true && want !== false) return this.check(check);
1340
+ let seen = await this.probeFreshSessions();
1341
+ if (seen === 'incomplete') return this.side === 'control' ? false : 'incomplete';
1342
+ if (seen === true) return true;
1343
+ const met = await this.poll(
1344
+ async () => {
1345
+ if (this.clients.flight?.abandoned) return true;
1346
+ seen = await this.probeFreshSessions();
1347
+ if (seen === 'incomplete') return true;
1348
+ return seen === true;
1349
+ },
1350
+ observeMs,
1351
+ false
1352
+ );
1353
+ if (seen === 'incomplete') return this.side === 'control' ? false : 'incomplete';
1354
+ if (want === true && !met && !this.clients.flight?.abandoned) {
1355
+ if (this.side === 'control') return false;
1356
+ this.incomplete.push(
1357
+ `workerReachable: probe evidence not observed within ${this.bound(observeMs)}ms`
1358
+ );
1359
+ return 'incomplete';
1360
+ }
1361
+ return seen === true;
1362
+ }
1363
+
1037
1364
  async watchPage(step) {
1038
1365
  if (!this.watch) {
1039
1366
  const start = this.reader.events.length;
@@ -1101,7 +1428,7 @@ class Execution {
1101
1428
  async settle(settle) {
1102
1429
  const refused = () => (settle.dependsOn ?? []).some((name) => this.refused[name]);
1103
1430
  const skipped = () => {
1104
- this.notes.push(
1431
+ this.note(
1105
1432
  `settle:skipped-refused ${settle.beacon ? `beacon ${settle.beacon}` : settle.served ? `request ${settle.served}` : settle.title ? `title ${settle.title}` : 'load'}`
1106
1433
  );
1107
1434
  };
@@ -1217,6 +1544,14 @@ class Execution {
1217
1544
  this.contexts.push(this.vars.context);
1218
1545
  await this.seedContext(this.vars.context);
1219
1546
  }
1547
+ if (step.reuseSession) {
1548
+ const reused = reusableAttachSession(step, out, params, this.client.events, this.eventStart);
1549
+ if (reused) {
1550
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, reused));
1551
+ this.note(`Target.attachToTarget reused auto-attach session for ${params.targetId}`);
1552
+ return { ok: true, result: { sessionId: reused, reused: true } };
1553
+ }
1554
+ }
1220
1555
  return out;
1221
1556
  }
1222
1557
 
@@ -1227,6 +1562,27 @@ class Execution {
1227
1562
  });
1228
1563
  }
1229
1564
 
1565
+ namedCallSummaries() {
1566
+ return Object.fromEntries(
1567
+ ['attach', 'spawn', 'targets', 'probe', 'tabs']
1568
+ .filter((name) => this.calls[name])
1569
+ .map((name) => {
1570
+ const out = this.calls[name];
1571
+ return [
1572
+ name,
1573
+ {
1574
+ ok: out.ok,
1575
+ ...(out.result?.sessionId ? { sessionId: true } : {}),
1576
+ ...(out.result?.exists !== undefined ? { exists: out.result.exists } : {}),
1577
+ ...(out.result?.reachable !== undefined ? { reachable: out.result.reachable } : {}),
1578
+ ...(out.result?.incomplete ? { incomplete: out.result.incomplete } : {}),
1579
+ ...(out.error ? { error: String(out.error?.message ?? out.error) } : {}),
1580
+ },
1581
+ ];
1582
+ })
1583
+ );
1584
+ }
1585
+
1230
1586
  async page(step, session, phase) {
1231
1587
  const client = this.actor();
1232
1588
  this.actionEventStart = client.events.length;
@@ -1338,20 +1694,30 @@ class Execution {
1338
1694
  return !!late;
1339
1695
  }
1340
1696
 
1341
- async observe(name, check) {
1342
- const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1343
- name
1344
- ];
1697
+ observeEarly(name, check, want) {
1698
+ if (name === 'workerReachable' && this.probeStep())
1699
+ return this.observeProbeReachable(want, check);
1345
1700
  if (check?.forwarded) return this.forwardedCheck(name, check, want);
1346
1701
  if (
1347
1702
  check?.result &&
1348
1703
  (at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
1349
1704
  )
1350
1705
  return 'incomplete';
1706
+ return undefined;
1707
+ }
1708
+
1709
+ async observe(name, check) {
1710
+ const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1711
+ name
1712
+ ];
1713
+ const early = this.observeEarly(name, check, want);
1714
+ if (early !== undefined) return early;
1351
1715
  const plan = steps(this.vector, this.side, this.vars);
1352
1716
  const names = resultNames(check);
1717
+ const absencePoll = this.vector.observe?.[name]?.absencePoll === true;
1353
1718
  const snapshots =
1354
- this.side === 'control' && want === true && !this.clients.flight?.abandoned
1719
+ !this.clients.flight?.abandoned &&
1720
+ ((this.side === 'control' && want === true) || (want === false && absencePoll))
1355
1721
  ? [...plan.setup, ...plan.trigger].filter(
1356
1722
  (step) =>
1357
1723
  names.includes(step.as) &&
@@ -1360,17 +1726,28 @@ class Execution {
1360
1726
  supported(step.requires, this.options.capabilities)
1361
1727
  )
1362
1728
  : [];
1729
+ const hasEvent = (node) =>
1730
+ Boolean(node?.event) ||
1731
+ [...(node?.all ?? []), ...(node?.any ?? []), ...(node?.not ? [node.not] : [])].some(hasEvent);
1363
1732
  const polled =
1364
1733
  check?.observer === 'fixture' || check?.world !== undefined || check?.script !== undefined;
1365
- if (want === false && (check?.world !== undefined || check?.script !== undefined))
1734
+ const shouldPoll =
1735
+ (want === true && (polled || snapshots.length > 0)) ||
1736
+ (want === false && absencePoll && snapshots.length > 0);
1737
+ if (
1738
+ want === false &&
1739
+ !shouldPoll &&
1740
+ (check?.world !== undefined || check?.script !== undefined || hasEvent(check))
1741
+ )
1366
1742
  return this.absenceCheck(check);
1367
- if (want !== true || (!polled && !snapshots.length)) return this.check(check);
1743
+ if (!shouldPoll) return this.check(check);
1368
1744
  let seen = false;
1369
1745
  const met = await this.poll(
1370
1746
  async () => {
1371
1747
  if (this.clients.flight?.abandoned) return true;
1372
1748
  seen = await this.check(check);
1373
1749
  if (seen === 'unwatched' || seen === 'incomplete') return true;
1750
+ if (want === false && seen === true) return true;
1374
1751
  if (seen !== true && snapshots.length) {
1375
1752
  await this.execute(snapshots, undefined, 'observation');
1376
1753
  if (this.clients.flight?.abandoned) return true;
@@ -1383,7 +1760,7 @@ class Execution {
1383
1760
  snapshots.length > 0
1384
1761
  );
1385
1762
  if (seen === 'unwatched' || seen === 'incomplete') return seen;
1386
- if ((!met || this.clients.flight?.abandoned) && snapshots.length) {
1763
+ if (want === true && (!met || this.clients.flight?.abandoned) && snapshots.length) {
1387
1764
  this.incomplete.push(
1388
1765
  `${name}: snapshot evidence not observed within ${this.bound(observeMs)}ms`
1389
1766
  );
@@ -1616,7 +1993,7 @@ class Execution {
1616
1993
  const observed = { ...this.refused };
1617
1994
  const incomplete = [];
1618
1995
  for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
1619
- if (!supported(observation.requires, this.options.capabilities)) {
1996
+ if (gatedOut(observation, this.options.capabilities)) {
1620
1997
  observed[name] = 'not-supported';
1621
1998
  continue;
1622
1999
  }
@@ -1668,6 +2045,7 @@ class Execution {
1668
2045
 
1669
2046
  async run() {
1670
2047
  this.requestStart = (await this.options.fixture.log()).length;
2048
+ this.installClientTrace();
1671
2049
  let pumping;
1672
2050
  try {
1673
2051
  await this.seed();
@@ -1690,6 +2068,7 @@ class Execution {
1690
2068
  side: this.side,
1691
2069
  });
1692
2070
  const { live, liveWhy } = await this.liveness(unseen, incomplete);
2071
+ if (this.errors.length) this.noteClientTrace();
1693
2072
  return {
1694
2073
  observed,
1695
2074
  live,
@@ -1698,6 +2077,7 @@ class Execution {
1698
2077
  notes: this.notes,
1699
2078
  incomplete: this.incomplete,
1700
2079
  unsupported: this.unsupported,
2080
+ calls: this.namedCallSummaries(),
1701
2081
  evidence: Object.fromEntries(
1702
2082
  Object.entries(this.calls)
1703
2083
  .filter(
@@ -1706,13 +2086,25 @@ class Execution {
1706
2086
  .map(([name, out]) => [name, out.result.diagnostics ?? out.result])
1707
2087
  ),
1708
2088
  };
2089
+ } catch (error) {
2090
+ this.noteClientTrace();
2091
+ throw error;
1709
2092
  } finally {
1710
2093
  this.stopped = true;
1711
2094
  await pumping;
1712
- if (this.watch)
1713
- await outcome(
1714
- this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
1715
- );
2095
+ await this.cleanupLeg();
2096
+ }
2097
+ }
2098
+
2099
+ async cleanupLeg() {
2100
+ if (this.watch)
2101
+ await outcome(
2102
+ this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
2103
+ );
2104
+ if (this.spawnWorkerKind()) {
2105
+ await Promise.allSettled(this.pending);
2106
+ await this.cleanupSpawnedWorkerLeg();
2107
+ } else {
1716
2108
  for (const session of this.attachSettings.keys())
1717
2109
  await outcome(
1718
2110
  this.client.send(
@@ -1723,23 +2115,49 @@ class Execution {
1723
2115
  );
1724
2116
  for (const targetId of new Set(this.targets))
1725
2117
  await outcome((this.reader ?? this.client).send('Target.closeTarget', { targetId }));
1726
- for (const browserContextId of new Set(this.contexts))
1727
- await outcome(
1728
- (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
1729
- );
1730
- await Promise.allSettled(this.pending);
1731
- if (
1732
- this.side === 'guarded' &&
1733
- this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
1734
- ) {
1735
- const violation = discoveryViolation(
1736
- this.client.events.slice(this.eventStart),
1737
- supported(['discovery'], this.options.capabilities)
1738
- );
1739
- if (violation) this.errors.push(violation);
1740
- }
2118
+ }
2119
+ for (const browserContextId of new Set(this.contexts))
2120
+ await outcome(
2121
+ (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
2122
+ );
2123
+ this.contexts = [];
2124
+ if (!this.spawnWorkerKind()) await Promise.allSettled(this.pending);
2125
+ if (
2126
+ this.side === 'guarded' &&
2127
+ this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
2128
+ ) {
2129
+ const violation = discoveryViolation(
2130
+ this.client.events.slice(this.eventStart),
2131
+ supported(['discovery'], this.options.capabilities)
2132
+ );
2133
+ if (violation) this.errors.push(violation);
1741
2134
  }
1742
2135
  }
2136
+
2137
+ async cleanupSpawnedWorkerLeg() {
2138
+ await this.resumeWaiting();
2139
+ await releasePersistentWorkerSessions({
2140
+ client: this.client,
2141
+ reader: this.reader,
2142
+ events: this.client.events,
2143
+ from: this.eventStart,
2144
+ tag: this.vars.tag,
2145
+ targets: this.targets,
2146
+ attachSessions: [...this.attachSettings.keys()],
2147
+ });
2148
+ if (this.reader && this.reader !== this.client)
2149
+ await releasePersistentWorkerSessions({
2150
+ client: this.reader,
2151
+ reader: this.reader,
2152
+ events: this.reader.events,
2153
+ from: 0,
2154
+ tag: this.vars.tag,
2155
+ targets: this.targets,
2156
+ attachSessions: [],
2157
+ });
2158
+ this.targets = [];
2159
+ this.attachSettings.clear();
2160
+ }
1743
2161
  }
1744
2162
 
1745
2163
  const usesPre = (value) =>
@@ -1757,7 +2175,7 @@ const observerRequired = (check) => {
1757
2175
  const needsObserver = (vector) =>
1758
2176
  vector.control.via === 'unguarded' ||
1759
2177
  allSteps([...(vector.setup ?? []), ...(vector.trigger ?? [])]).some(
1760
- (step) => step.isolatedWorld || step.observer
2178
+ (step) => step.isolatedWorld || step.observer || step.spawnWorker
1761
2179
  ) ||
1762
2180
  observerRequired(vector.liveCheck) ||
1763
2181
  Object.values(vector.observe ?? {}).some(
@@ -1863,14 +2281,75 @@ function variables(targets, target, side, slot, index) {
1863
2281
  return vars;
1864
2282
  }
1865
2283
 
2284
+ function sessionTypeOf(client, sessionId) {
2285
+ if (!sessionId) return 'browser';
2286
+ const events = client.events ?? [];
2287
+ for (let i = events.length - 1; i >= 0; i--) {
2288
+ const event = events[i];
2289
+ if (event.method === 'Target.attachedToTarget' && event.params?.sessionId === sessionId)
2290
+ return event.params.targetInfo?.type || 'unknown';
2291
+ }
2292
+ return 'unknown';
2293
+ }
2294
+
2295
+ function boundFrameTree(rawSend, client, params, sessionId) {
2296
+ let timer;
2297
+ const type = sessionTypeOf(client, sessionId);
2298
+ return Promise.race([
2299
+ rawSend('Page.getFrameTree', params, sessionId),
2300
+ new Promise((_, reject) => {
2301
+ timer = setTimeout(
2302
+ () =>
2303
+ reject(
2304
+ new Error(
2305
+ `Page.getFrameTree timed out on ${type} session ${sessionId ?? 'browser'} after 3000ms`
2306
+ )
2307
+ ),
2308
+ 3000
2309
+ );
2310
+ }),
2311
+ ]).finally(() => clearTimeout(timer));
2312
+ }
2313
+
1866
2314
  function tracked(client, calls, name) {
1867
2315
  if (!client || typeof client.send !== 'function') return client;
1868
2316
  let next = 0;
2317
+ const rawSend = client.send.bind(client);
1869
2318
  const send = (method, params, sessionId) => {
1870
2319
  const id = ++next;
1871
2320
  calls.set(id, { name, method, session: sessionId, since: Date.now() });
2321
+ if (!client.__sliccOnSend && !client.__sliccBoundGetFrameTree) {
2322
+ return Promise.resolve()
2323
+ .then(() => client.send(method, params, sessionId))
2324
+ .finally(() => calls.delete(id));
2325
+ }
2326
+ const started = Date.now();
2327
+ const run =
2328
+ method === 'Page.getFrameTree' && client.__sliccBoundGetFrameTree
2329
+ ? () => boundFrameTree(rawSend, client, params, sessionId)
2330
+ : () => rawSend(method, params, sessionId);
1872
2331
  return Promise.resolve()
1873
- .then(() => client.send(method, params, sessionId))
2332
+ .then(run)
2333
+ .then(
2334
+ (result) => {
2335
+ client.__sliccOnSend?.({
2336
+ method,
2337
+ sessionId,
2338
+ ms: Date.now() - started,
2339
+ status: 'ok',
2340
+ });
2341
+ return result;
2342
+ },
2343
+ (error) => {
2344
+ client.__sliccOnSend?.({
2345
+ method,
2346
+ sessionId,
2347
+ ms: Date.now() - started,
2348
+ status: String(error?.message ?? error).slice(0, 200),
2349
+ });
2350
+ throw error;
2351
+ }
2352
+ )
1874
2353
  .finally(() => calls.delete(id));
1875
2354
  };
1876
2355
  return new Proxy(client, {
@@ -2018,12 +2497,27 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2018
2497
  }
2019
2498
  if (legs.control.unsupported === 'trustedEmulation')
2020
2499
  return skipRow(base, 'not-supported:trustedEmulation');
2500
+ const row = finishRow(vector, base, legs, opts);
2501
+ opts.log?.(row);
2502
+ return row;
2503
+ }
2504
+
2505
+ function finishRow(vector, base, legs, opts) {
2506
+ const caps = Array.isArray(opts.capabilities)
2507
+ ? opts.capabilities
2508
+ : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true);
2509
+ const calls = {
2510
+ ...(legs.control.calls && Object.keys(legs.control.calls).length
2511
+ ? { control: legs.control.calls }
2512
+ : {}),
2513
+ ...(legs.guarded.calls && Object.keys(legs.guarded.calls).length
2514
+ ? { guarded: legs.guarded.calls }
2515
+ : {}),
2516
+ };
2021
2517
  const row = {
2022
2518
  ...result(vector, {
2023
2519
  ...base,
2024
- capabilities: Array.isArray(opts.capabilities)
2025
- ? opts.capabilities
2026
- : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true),
2520
+ capabilities: caps,
2027
2521
  control: legs.control.observed,
2028
2522
  guarded: legs.guarded.observed,
2029
2523
  live: legs.control.live && legs.guarded.live,
@@ -2033,6 +2527,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2033
2527
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },
2034
2528
  errors: [...legs.control.errors, ...legs.guarded.errors],
2035
2529
  designDependent: vector.designDependent ?? [],
2530
+ ...(Object.keys(calls).length ? { calls } : {}),
2036
2531
  ...probeEvidence(legs),
2037
2532
  };
2038
2533
  const notes = [
@@ -2045,7 +2540,6 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2045
2540
  row.control = 'fail';
2046
2541
  row.guarded = 'fail';
2047
2542
  }
2048
- opts.log?.(row);
2049
2543
  return row;
2050
2544
  }
2051
2545