@ai-ecoverse/slicc-browsers 1.22.0 → 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;
@@ -171,12 +207,71 @@ class Execution {
171
207
  this.refusalScale = options.refusalScale ?? 1;
172
208
  this.childRuns = new Map();
173
209
  this.named = new Map();
210
+ this.eventMarks = new Map();
174
211
  this.processed = new Set();
175
212
  this.eventStart = this.client.events.length;
176
213
  this.actionEventStart = this.eventStart;
177
214
  this.requestStart = undefined;
178
215
  this.trace = [];
179
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
+ );
180
275
  }
181
276
 
182
277
  actor() {
@@ -199,8 +294,7 @@ class Execution {
199
294
  );
200
295
  return read.ok && read.result.targetInfo?.attached === false;
201
296
  }, 5000);
202
- if (!released)
203
- 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`);
204
298
  const { sessionId } = await this.reader.send('Target.attachToTarget', {
205
299
  targetId: this.pageTarget,
206
300
  flatten: true,
@@ -407,53 +501,55 @@ class Execution {
407
501
  );
408
502
  }
409
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
+
410
521
  async blocked(spec, observer) {
411
522
  const urls = (spec.anyOf ?? [spec.url]).map(address);
412
523
  if (observer === 'client') return this.clientBlocked(spec, urls);
413
524
  const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
414
525
  if (!targetId) return false;
415
- const attached = await outcome(
416
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
417
- );
526
+ const attached = await this.readerFrameTree(targetId, 'blocked page');
418
527
  if (!attached.ok) return false;
419
528
  try {
420
- const read = await outcome(
421
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
422
- );
423
529
  const blockedFrame = (frame) =>
424
530
  Boolean(
425
531
  frame && urls.includes(frame.unreachableUrl) && frame.url.startsWith('chrome-error://')
426
532
  );
427
533
  const visit = (tree) =>
428
534
  Boolean(tree && (blockedFrame(tree.frame) || (tree.childFrames ?? []).some(visit)));
429
- if (!read.ok) return false;
430
535
  if (spec.frame) {
431
- if ((read.result.frameTree.childFrames ?? []).some(visit)) return true;
536
+ if ((attached.frameTree.childFrames ?? []).some(visit)) return true;
432
537
  return Boolean(await this.blockedFrameTarget(urls, targetId));
433
538
  }
434
- return blockedFrame(read.result.frameTree.frame);
539
+ return blockedFrame(attached.frameTree.frame);
435
540
  } finally {
436
- await outcome(
437
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
438
- );
541
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
439
542
  }
440
543
  }
441
544
 
442
545
  async frameIds(targetId) {
443
- const attached = await outcome(
444
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
445
- );
546
+ const attached = await this.readerFrameTree(targetId, 'frameIds page');
446
547
  if (!attached.ok) return [];
447
548
  try {
448
- const read = await outcome(
449
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
450
- );
451
549
  const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
452
- return read.ok ? ids(read.result.frameTree) : [];
550
+ return ids(attached.frameTree);
453
551
  } finally {
454
- await outcome(
455
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
456
- );
552
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
457
553
  }
458
554
  }
459
555
 
@@ -463,21 +559,16 @@ class Execution {
463
559
  if (!listed.ok) return undefined;
464
560
  for (const info of listed.result.targetInfos) {
465
561
  if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
466
- const attached = await outcome(
467
- this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
468
- );
469
- if (!attached.ok) continue;
562
+ let sessionId;
470
563
  try {
471
- const read = await outcome(
472
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
473
- );
474
- 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;
475
568
  if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
476
569
  return info.targetId;
477
570
  } finally {
478
- await outcome(
479
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
480
- );
571
+ if (sessionId) await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
481
572
  }
482
573
  }
483
574
  return undefined;
@@ -531,7 +622,7 @@ class Execution {
531
622
  } catch (error) {
532
623
  const message = String(error?.message ?? error);
533
624
  if (!/timed? ?out/i.test(message)) throw error;
534
- this.notes.push(`Target.createBrowserContext retried once after: ${message}`);
625
+ this.note(`Target.createBrowserContext retried once after: ${message}`);
535
626
  return (await client.send('Target.createBrowserContext', {})).browserContextId;
536
627
  }
537
628
  }
@@ -593,17 +684,38 @@ class Execution {
593
684
  }
594
685
  }
595
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
+
596
700
  adopt(event) {
597
701
  const info = event.params.targetInfo;
598
702
  const child = event.params.sessionId;
599
703
  if (info?.type === 'browser_ui') return;
600
704
  const paused = event.params.waitingForDebugger || this.tabSessions.has(event.sessionId);
705
+ const workerRow = Boolean(this.spawnWorkerKind());
601
706
  if (this.expectWhere && !this.childWhere) {
602
707
  this.deferred.push(event);
603
708
  return;
604
709
  }
605
- 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)));
606
717
  return;
718
+ }
607
719
  const matched = !this.childWhere || this.childMatches(info);
608
720
  const isTab = info?.type === 'tab';
609
721
  if (!matched && !isTab) {
@@ -620,6 +732,27 @@ class Execution {
620
732
  ? this.childSteps
621
733
  : this.childSteps.filter((step) => step.cdp === 'Target.setAutoAttach');
622
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
+ }
623
756
  const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
624
757
  if (reload < 0) await this.execute(nested, child);
625
758
  else {
@@ -639,7 +772,13 @@ class Execution {
639
772
  if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
640
773
  if (nested.some((step) => step.cdp === 'Page.enable'))
641
774
  await this.poll(() => this.firstLoad(child, resumed), 5000);
642
- })().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
+ );
643
782
  this.childRuns.set(child, started);
644
783
  this.pending.push(started);
645
784
  }
@@ -704,7 +843,11 @@ class Execution {
704
843
  }
705
844
 
706
845
  childMatches(info) {
707
- 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
+ }
708
851
  if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
709
852
  if (this.childWhere.urlPrefix !== undefined)
710
853
  return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
@@ -791,7 +934,7 @@ class Execution {
791
934
  Boolean(childSession) && !out.ok && this.detached(childSession)
792
935
  );
793
936
  if ((step.isolatedWorld || step.observer) && !out.ok)
794
- this.notes.push(
937
+ this.note(
795
938
  `${step.isolatedWorld ? `isolatedWorld ${step.isolatedWorld}` : `observer ${step.observer}`}: ${out.error?.message ?? 'failed'}`
796
939
  );
797
940
  this.options.debug?.({ step, out, side: this.side });
@@ -826,7 +969,7 @@ class Execution {
826
969
  return Boolean(next);
827
970
  }, browserUiMs);
828
971
  if (!next) return undefined;
829
- this.notes.push(
972
+ this.note(
830
973
  `{browserUi} ${vanished} was gone when ${step.cdp} reached it; retried with ${next.targetId}`
831
974
  );
832
975
  this.vars.browserUi = next.targetId;
@@ -857,48 +1000,9 @@ class Execution {
857
1000
  return;
858
1001
  }
859
1002
  if (step.client) return outcome(this.exit());
860
- if (step.redirectSecret) {
861
- this.internalSessions.add(session);
862
- try {
863
- return await outcome(
864
- redirectSecret({
865
- client: this.client,
866
- reader: this.reader,
867
- session,
868
- targetId: this.pageTarget,
869
- ordinary: this.vars.ordinary,
870
- tag: this.vars.tag,
871
- fixture: this.options.fixture,
872
- mode: step.redirectSecret,
873
- side: this.side,
874
- settleScale: this.scale,
875
- controls: (this.clients.redirectSecretControls ??= {}),
876
- internalSessions: this.internalSessions,
877
- })
878
- );
879
- } finally {
880
- this.internalSessions.delete(session);
881
- }
882
- }
883
- if (step.errorPage)
884
- return outcome(
885
- protectedErrorPage({
886
- client: this.client,
887
- reader: this.reader,
888
- session,
889
- targetId: this.pageTarget,
890
- ordinary: this.vars.ordinary,
891
- protectedOrigin: this.vars.seven,
892
- tag: this.vars.tag,
893
- internalSessions: this.internalSessions,
894
- mode: step.errorPage,
895
- side: this.side,
896
- secret: step.secret,
897
- discovery: supported(['discovery'], this.options.capabilities),
898
- notes: this.notes,
899
- budget: this.bound(5000),
900
- })
901
- );
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);
902
1006
  if (step.bfcacheRestore)
903
1007
  return outcome(
904
1008
  bfcacheRestore({
@@ -1009,12 +1113,10 @@ class Execution {
1009
1113
 
1010
1114
  async isolatedWorld(step) {
1011
1115
  const targetId = step.session === 'child' ? this.targets.at(-1) : this.pageTarget;
1012
- const { sessionId } = await this.reader.send('Target.attachToTarget', {
1013
- targetId,
1014
- flatten: true,
1015
- });
1116
+ const attached = await this.readerFrameTree(targetId, 'isolatedWorld page');
1117
+ if (!attached.ok) throw new Error('isolatedWorld attach failed');
1118
+ const { sessionId, frameTree } = attached;
1016
1119
  try {
1017
- const { frameTree } = await this.reader.send('Page.getFrameTree', {}, sessionId);
1018
1120
  const { executionContextId } = await this.reader.send(
1019
1121
  'Page.createIsolatedWorld',
1020
1122
  { frameId: frameTree.frame.id, worldName: step.isolatedWorld },
@@ -1041,6 +1143,224 @@ class Execution {
1041
1143
  }
1042
1144
  }
1043
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
+
1044
1364
  async watchPage(step) {
1045
1365
  if (!this.watch) {
1046
1366
  const start = this.reader.events.length;
@@ -1108,7 +1428,7 @@ class Execution {
1108
1428
  async settle(settle) {
1109
1429
  const refused = () => (settle.dependsOn ?? []).some((name) => this.refused[name]);
1110
1430
  const skipped = () => {
1111
- this.notes.push(
1431
+ this.note(
1112
1432
  `settle:skipped-refused ${settle.beacon ? `beacon ${settle.beacon}` : settle.served ? `request ${settle.served}` : settle.title ? `title ${settle.title}` : 'load'}`
1113
1433
  );
1114
1434
  };
@@ -1224,6 +1544,14 @@ class Execution {
1224
1544
  this.contexts.push(this.vars.context);
1225
1545
  await this.seedContext(this.vars.context);
1226
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
+ }
1227
1555
  return out;
1228
1556
  }
1229
1557
 
@@ -1234,6 +1562,27 @@ class Execution {
1234
1562
  });
1235
1563
  }
1236
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
+
1237
1586
  async page(step, session, phase) {
1238
1587
  const client = this.actor();
1239
1588
  this.actionEventStart = client.events.length;
@@ -1345,20 +1694,30 @@ class Execution {
1345
1694
  return !!late;
1346
1695
  }
1347
1696
 
1348
- async observe(name, check) {
1349
- const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1350
- name
1351
- ];
1697
+ observeEarly(name, check, want) {
1698
+ if (name === 'workerReachable' && this.probeStep())
1699
+ return this.observeProbeReachable(want, check);
1352
1700
  if (check?.forwarded) return this.forwardedCheck(name, check, want);
1353
1701
  if (
1354
1702
  check?.result &&
1355
1703
  (at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
1356
1704
  )
1357
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;
1358
1715
  const plan = steps(this.vector, this.side, this.vars);
1359
1716
  const names = resultNames(check);
1717
+ const absencePoll = this.vector.observe?.[name]?.absencePoll === true;
1360
1718
  const snapshots =
1361
- this.side === 'control' && want === true && !this.clients.flight?.abandoned
1719
+ !this.clients.flight?.abandoned &&
1720
+ ((this.side === 'control' && want === true) || (want === false && absencePoll))
1362
1721
  ? [...plan.setup, ...plan.trigger].filter(
1363
1722
  (step) =>
1364
1723
  names.includes(step.as) &&
@@ -1367,17 +1726,28 @@ class Execution {
1367
1726
  supported(step.requires, this.options.capabilities)
1368
1727
  )
1369
1728
  : [];
1729
+ const hasEvent = (node) =>
1730
+ Boolean(node?.event) ||
1731
+ [...(node?.all ?? []), ...(node?.any ?? []), ...(node?.not ? [node.not] : [])].some(hasEvent);
1370
1732
  const polled =
1371
1733
  check?.observer === 'fixture' || check?.world !== undefined || check?.script !== undefined;
1372
- 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
+ )
1373
1742
  return this.absenceCheck(check);
1374
- if (want !== true || (!polled && !snapshots.length)) return this.check(check);
1743
+ if (!shouldPoll) return this.check(check);
1375
1744
  let seen = false;
1376
1745
  const met = await this.poll(
1377
1746
  async () => {
1378
1747
  if (this.clients.flight?.abandoned) return true;
1379
1748
  seen = await this.check(check);
1380
1749
  if (seen === 'unwatched' || seen === 'incomplete') return true;
1750
+ if (want === false && seen === true) return true;
1381
1751
  if (seen !== true && snapshots.length) {
1382
1752
  await this.execute(snapshots, undefined, 'observation');
1383
1753
  if (this.clients.flight?.abandoned) return true;
@@ -1390,7 +1760,7 @@ class Execution {
1390
1760
  snapshots.length > 0
1391
1761
  );
1392
1762
  if (seen === 'unwatched' || seen === 'incomplete') return seen;
1393
- if ((!met || this.clients.flight?.abandoned) && snapshots.length) {
1763
+ if (want === true && (!met || this.clients.flight?.abandoned) && snapshots.length) {
1394
1764
  this.incomplete.push(
1395
1765
  `${name}: snapshot evidence not observed within ${this.bound(observeMs)}ms`
1396
1766
  );
@@ -1675,6 +2045,7 @@ class Execution {
1675
2045
 
1676
2046
  async run() {
1677
2047
  this.requestStart = (await this.options.fixture.log()).length;
2048
+ this.installClientTrace();
1678
2049
  let pumping;
1679
2050
  try {
1680
2051
  await this.seed();
@@ -1697,6 +2068,7 @@ class Execution {
1697
2068
  side: this.side,
1698
2069
  });
1699
2070
  const { live, liveWhy } = await this.liveness(unseen, incomplete);
2071
+ if (this.errors.length) this.noteClientTrace();
1700
2072
  return {
1701
2073
  observed,
1702
2074
  live,
@@ -1705,6 +2077,7 @@ class Execution {
1705
2077
  notes: this.notes,
1706
2078
  incomplete: this.incomplete,
1707
2079
  unsupported: this.unsupported,
2080
+ calls: this.namedCallSummaries(),
1708
2081
  evidence: Object.fromEntries(
1709
2082
  Object.entries(this.calls)
1710
2083
  .filter(
@@ -1713,13 +2086,25 @@ class Execution {
1713
2086
  .map(([name, out]) => [name, out.result.diagnostics ?? out.result])
1714
2087
  ),
1715
2088
  };
2089
+ } catch (error) {
2090
+ this.noteClientTrace();
2091
+ throw error;
1716
2092
  } finally {
1717
2093
  this.stopped = true;
1718
2094
  await pumping;
1719
- if (this.watch)
1720
- await outcome(
1721
- this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
1722
- );
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 {
1723
2108
  for (const session of this.attachSettings.keys())
1724
2109
  await outcome(
1725
2110
  this.client.send(
@@ -1730,23 +2115,49 @@ class Execution {
1730
2115
  );
1731
2116
  for (const targetId of new Set(this.targets))
1732
2117
  await outcome((this.reader ?? this.client).send('Target.closeTarget', { targetId }));
1733
- for (const browserContextId of new Set(this.contexts))
1734
- await outcome(
1735
- (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
1736
- );
1737
- await Promise.allSettled(this.pending);
1738
- if (
1739
- this.side === 'guarded' &&
1740
- this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
1741
- ) {
1742
- const violation = discoveryViolation(
1743
- this.client.events.slice(this.eventStart),
1744
- supported(['discovery'], this.options.capabilities)
1745
- );
1746
- if (violation) this.errors.push(violation);
1747
- }
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);
1748
2134
  }
1749
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
+ }
1750
2161
  }
1751
2162
 
1752
2163
  const usesPre = (value) =>
@@ -1764,7 +2175,7 @@ const observerRequired = (check) => {
1764
2175
  const needsObserver = (vector) =>
1765
2176
  vector.control.via === 'unguarded' ||
1766
2177
  allSteps([...(vector.setup ?? []), ...(vector.trigger ?? [])]).some(
1767
- (step) => step.isolatedWorld || step.observer
2178
+ (step) => step.isolatedWorld || step.observer || step.spawnWorker
1768
2179
  ) ||
1769
2180
  observerRequired(vector.liveCheck) ||
1770
2181
  Object.values(vector.observe ?? {}).some(
@@ -1870,14 +2281,75 @@ function variables(targets, target, side, slot, index) {
1870
2281
  return vars;
1871
2282
  }
1872
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
+
1873
2314
  function tracked(client, calls, name) {
1874
2315
  if (!client || typeof client.send !== 'function') return client;
1875
2316
  let next = 0;
2317
+ const rawSend = client.send.bind(client);
1876
2318
  const send = (method, params, sessionId) => {
1877
2319
  const id = ++next;
1878
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);
1879
2331
  return Promise.resolve()
1880
- .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
+ )
1881
2353
  .finally(() => calls.delete(id));
1882
2354
  };
1883
2355
  return new Proxy(client, {
@@ -2025,12 +2497,27 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2025
2497
  }
2026
2498
  if (legs.control.unsupported === 'trustedEmulation')
2027
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
+ };
2028
2517
  const row = {
2029
2518
  ...result(vector, {
2030
2519
  ...base,
2031
- capabilities: Array.isArray(opts.capabilities)
2032
- ? opts.capabilities
2033
- : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true),
2520
+ capabilities: caps,
2034
2521
  control: legs.control.observed,
2035
2522
  guarded: legs.guarded.observed,
2036
2523
  live: legs.control.live && legs.guarded.live,
@@ -2040,6 +2527,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2040
2527
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },
2041
2528
  errors: [...legs.control.errors, ...legs.guarded.errors],
2042
2529
  designDependent: vector.designDependent ?? [],
2530
+ ...(Object.keys(calls).length ? { calls } : {}),
2043
2531
  ...probeEvidence(legs),
2044
2532
  };
2045
2533
  const notes = [
@@ -2052,7 +2540,6 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2052
2540
  row.control = 'fail';
2053
2541
  row.guarded = 'fail';
2054
2542
  }
2055
- opts.log?.(row);
2056
2543
  return row;
2057
2544
  }
2058
2545