@ai-ecoverse/slicc-browsers 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/guard-runner.mjs CHANGED
@@ -3,6 +3,7 @@ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
3
3
  export { fixtureClient };
4
4
 
5
5
  import { bfcacheRestore } from './bfcache-restore.mjs';
6
+ import { closeProtectedShape } from './close-protected-shape.mjs';
6
7
  import { extensionFrame } from './extension-frame.mjs';
7
8
  import { DEFAULT_EXTENSION_WORLD_ID, extensionWorld } from './extension-world.mjs';
8
9
  import { frameRead } from './frame-read.mjs';
@@ -31,12 +32,48 @@ import {
31
32
  } from './guard-driver.mjs';
32
33
  import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
33
34
  import { protectedErrorPage } from './protected-error-page.mjs';
35
+ import {
36
+ leftoverPersistentWorker,
37
+ persistentWorkerTypes,
38
+ probeHarnessIncomplete,
39
+ probeWorkerSessions,
40
+ protectedWorker,
41
+ releasePersistentWorkerSessions,
42
+ workerTypeForKind,
43
+ } from './protected-worker.mjs';
34
44
  import { redirectSecret } from './redirect-secret.mjs';
35
45
  import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
36
46
 
47
+ export { leftoverPersistentWorker, releasePersistentWorkerSessions, workerTypeForKind };
48
+
37
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38
50
  const namedResultMs = 10000;
39
51
  const observeMs = 5000;
52
+
53
+ function autoAttachSession(events, start, targetId) {
54
+ return events
55
+ .slice(start)
56
+ .filter(
57
+ (event) =>
58
+ event.method === 'Target.attachedToTarget' &&
59
+ event.params?.targetInfo?.targetId === targetId &&
60
+ event.params?.sessionId
61
+ )
62
+ .at(-1)?.params?.sessionId;
63
+ }
64
+
65
+ export function reusableAttachSession(step, out, params, events, start) {
66
+ if (!step?.reuseSession || step.cdp !== 'Target.attachToTarget' || !params?.targetId)
67
+ return undefined;
68
+ if (out?.ok && out.result?.sessionId) return undefined;
69
+ const message = String(out?.error?.message ?? '');
70
+ if (/refused|not allowed/i.test(message)) return undefined;
71
+ const already =
72
+ (out?.ok === true && !out.result?.sessionId) ||
73
+ /already attached|attached already|Target is attaching or attached/i.test(message);
74
+ if (!already) return undefined;
75
+ return autoAttachSession(events, start, params.targetId);
76
+ }
40
77
  const browserUiAttempts = 3;
41
78
  const browserUiMs = 5000;
42
79
  const q = JSON.stringify;
@@ -158,6 +195,7 @@ class Execution {
158
195
  this.unsupported = undefined;
159
196
  this.preSent = [];
160
197
  this.notes = [];
198
+ this.sharedNotes = Array.isArray(options.notes) ? options.notes : undefined;
161
199
  this.answers = [];
162
200
  this.internalSessions = new Set();
163
201
  this.attachSettings = new Map();
@@ -171,12 +209,75 @@ class Execution {
171
209
  this.refusalScale = options.refusalScale ?? 1;
172
210
  this.childRuns = new Map();
173
211
  this.named = new Map();
212
+ this.eventMarks = new Map();
174
213
  this.processed = new Set();
175
214
  this.eventStart = this.client.events.length;
176
215
  this.actionEventStart = this.eventStart;
177
216
  this.requestStart = undefined;
178
217
  this.trace = [];
179
218
  this.stopped = false;
219
+ this.cdpTrace = [];
220
+ this.cdpSessionTypes = new Map();
221
+ this.cdpTraceInstalled = false;
222
+ }
223
+
224
+ note(message) {
225
+ if (this.notes.length >= 80) this.notes.shift();
226
+ Array.prototype.push.call(this.notes, message);
227
+ if (this.sharedNotes && this.sharedNotes !== this.notes) {
228
+ if (this.sharedNotes.length >= 80) this.sharedNotes.shift();
229
+ Array.prototype.push.call(this.sharedNotes, message);
230
+ }
231
+ }
232
+
233
+ installClientTrace() {
234
+ if (!this.spawnWorkerKind() || this.cdpTraceInstalled) return;
235
+ this.cdpTraceInstalled = true;
236
+ const prev = this.client.__sliccOnSend;
237
+ this.client.__sliccBoundGetFrameTree = true;
238
+ this.client.__sliccOnSend = (entry) => {
239
+ prev?.(entry);
240
+ this.recordCdpTrace(entry);
241
+ };
242
+ }
243
+
244
+ recordCdpTrace(entry) {
245
+ let type = entry.sessionId ? this.cdpSessionTypes.get(entry.sessionId) : 'browser';
246
+ if (entry.sessionId && type === undefined) {
247
+ type = '';
248
+ const events = this.client.events;
249
+ for (let i = events.length - 1; i >= 0; i--) {
250
+ const event = events[i];
251
+ if (
252
+ event.method === 'Target.attachedToTarget' &&
253
+ event.params?.sessionId === entry.sessionId
254
+ ) {
255
+ type = event.params.targetInfo?.type ?? '';
256
+ break;
257
+ }
258
+ }
259
+ this.cdpSessionTypes.set(entry.sessionId, type);
260
+ }
261
+ if (this.cdpTrace.length >= 40) this.cdpTrace.shift();
262
+ this.cdpTrace.push({
263
+ method: entry.method,
264
+ sessionId: entry.sessionId ?? '',
265
+ type: type || (entry.sessionId ? '?' : 'browser'),
266
+ ms: entry.ms,
267
+ status: String(entry.status).slice(0, 200),
268
+ });
269
+ }
270
+
271
+ noteClientTrace() {
272
+ if (!this.cdpTrace.length) return;
273
+ this.note(
274
+ `client cdp: ${this.cdpTrace
275
+ .map(
276
+ (call) =>
277
+ `${call.method}@${call.sessionId || 'browser'}:${call.type || '?'} ${call.ms}ms ${call.status}`
278
+ )
279
+ .join(' | ')}`
280
+ );
180
281
  }
181
282
 
182
283
  actor() {
@@ -199,8 +300,7 @@ class Execution {
199
300
  );
200
301
  return read.ok && read.result.targetInfo?.attached === false;
201
302
  }, 5000);
202
- if (!released)
203
- this.notes.push(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
303
+ if (!released) this.note(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
204
304
  const { sessionId } = await this.reader.send('Target.attachToTarget', {
205
305
  targetId: this.pageTarget,
206
306
  flatten: true,
@@ -407,53 +507,55 @@ class Execution {
407
507
  );
408
508
  }
409
509
 
510
+ async readerFrameTree(targetId, label) {
511
+ const attached = await outcome(
512
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
513
+ );
514
+ if (!attached.ok) return { ok: false, sessionId: undefined, frameTree: undefined };
515
+ const sessionId = attached.result.sessionId;
516
+ await outcome(this.reader.send('Runtime.runIfWaitingForDebugger', {}, sessionId));
517
+ const read = await outcome(this.reader.send('Page.getFrameTree', {}, sessionId));
518
+ if (!read.ok) {
519
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
520
+ throw new Error(
521
+ `Page.getFrameTree timed out on ${label} target ${targetId} session ${sessionId}: ${read.error?.message ?? 'unknown'}`
522
+ );
523
+ }
524
+ return { ok: true, sessionId, frameTree: read.result.frameTree };
525
+ }
526
+
410
527
  async blocked(spec, observer) {
411
528
  const urls = (spec.anyOf ?? [spec.url]).map(address);
412
529
  if (observer === 'client') return this.clientBlocked(spec, urls);
413
530
  const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
414
531
  if (!targetId) return false;
415
- const attached = await outcome(
416
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
417
- );
532
+ const attached = await this.readerFrameTree(targetId, 'blocked page');
418
533
  if (!attached.ok) return false;
419
534
  try {
420
- const read = await outcome(
421
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
422
- );
423
535
  const blockedFrame = (frame) =>
424
536
  Boolean(
425
537
  frame && urls.includes(frame.unreachableUrl) && frame.url.startsWith('chrome-error://')
426
538
  );
427
539
  const visit = (tree) =>
428
540
  Boolean(tree && (blockedFrame(tree.frame) || (tree.childFrames ?? []).some(visit)));
429
- if (!read.ok) return false;
430
541
  if (spec.frame) {
431
- if ((read.result.frameTree.childFrames ?? []).some(visit)) return true;
542
+ if ((attached.frameTree.childFrames ?? []).some(visit)) return true;
432
543
  return Boolean(await this.blockedFrameTarget(urls, targetId));
433
544
  }
434
- return blockedFrame(read.result.frameTree.frame);
545
+ return blockedFrame(attached.frameTree.frame);
435
546
  } finally {
436
- await outcome(
437
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
438
- );
547
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
439
548
  }
440
549
  }
441
550
 
442
551
  async frameIds(targetId) {
443
- const attached = await outcome(
444
- this.reader.send('Target.attachToTarget', { targetId, flatten: true })
445
- );
552
+ const attached = await this.readerFrameTree(targetId, 'frameIds page');
446
553
  if (!attached.ok) return [];
447
554
  try {
448
- const read = await outcome(
449
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
450
- );
451
555
  const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
452
- return read.ok ? ids(read.result.frameTree) : [];
556
+ return ids(attached.frameTree);
453
557
  } finally {
454
- await outcome(
455
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
456
- );
558
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: attached.sessionId }));
457
559
  }
458
560
  }
459
561
 
@@ -463,21 +565,16 @@ class Execution {
463
565
  if (!listed.ok) return undefined;
464
566
  for (const info of listed.result.targetInfos) {
465
567
  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;
568
+ let sessionId;
470
569
  try {
471
- const read = await outcome(
472
- this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
473
- );
474
- const frame = read.result?.frameTree?.frame;
570
+ const attached = await this.readerFrameTree(info.targetId, `iframe ${info.type}`);
571
+ if (!attached.ok) continue;
572
+ sessionId = attached.sessionId;
573
+ const frame = attached.frameTree?.frame;
475
574
  if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
476
575
  return info.targetId;
477
576
  } finally {
478
- await outcome(
479
- this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
480
- );
577
+ if (sessionId) await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
481
578
  }
482
579
  }
483
580
  return undefined;
@@ -531,7 +628,7 @@ class Execution {
531
628
  } catch (error) {
532
629
  const message = String(error?.message ?? error);
533
630
  if (!/timed? ?out/i.test(message)) throw error;
534
- this.notes.push(`Target.createBrowserContext retried once after: ${message}`);
631
+ this.note(`Target.createBrowserContext retried once after: ${message}`);
535
632
  return (await client.send('Target.createBrowserContext', {})).browserContextId;
536
633
  }
537
634
  }
@@ -593,17 +690,38 @@ class Execution {
593
690
  }
594
691
  }
595
692
 
693
+ async resumeWaiting(from = this.eventStart) {
694
+ for (const event of this.client.events.slice(from)) {
695
+ if (
696
+ event.method === 'Target.attachedToTarget' &&
697
+ event.params?.waitingForDebugger &&
698
+ event.params?.sessionId
699
+ )
700
+ await outcome(
701
+ this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
702
+ );
703
+ }
704
+ }
705
+
596
706
  adopt(event) {
597
707
  const info = event.params.targetInfo;
598
708
  const child = event.params.sessionId;
599
709
  if (info?.type === 'browser_ui') return;
600
710
  const paused = event.params.waitingForDebugger || this.tabSessions.has(event.sessionId);
711
+ const workerRow = Boolean(this.spawnWorkerKind());
601
712
  if (this.expectWhere && !this.childWhere) {
602
713
  this.deferred.push(event);
603
714
  return;
604
715
  }
605
- if (info?.browserContextId && this.vars.context && info.browserContextId !== this.vars.context)
716
+ if (
717
+ info?.browserContextId &&
718
+ this.vars.context &&
719
+ info.browserContextId !== this.vars.context
720
+ ) {
721
+ if (paused && workerRow)
722
+ this.pending.push(outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child)));
606
723
  return;
724
+ }
607
725
  const matched = !this.childWhere || this.childMatches(info);
608
726
  const isTab = info?.type === 'tab';
609
727
  if (!matched && !isTab) {
@@ -620,6 +738,27 @@ class Execution {
620
738
  ? this.childSteps
621
739
  : this.childSteps.filter((step) => step.cdp === 'Target.setAutoAttach');
622
740
  const started = (async () => {
741
+ if (workerRow) {
742
+ if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
743
+ const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
744
+ if (reload < 0) await this.execute(nested, child);
745
+ else {
746
+ await this.execute(nested.slice(0, reload), child);
747
+ const start = this.client.events.length;
748
+ await this.poll(
749
+ () =>
750
+ this.client.events
751
+ .slice(start)
752
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === child),
753
+ 1500
754
+ );
755
+ await this.execute(nested.slice(reload), child);
756
+ }
757
+ const resumed = this.client.events.length;
758
+ if (nested.some((step) => step.cdp === 'Page.enable'))
759
+ await this.poll(() => this.firstLoad(child, resumed), 5000);
760
+ return;
761
+ }
623
762
  const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
624
763
  if (reload < 0) await this.execute(nested, child);
625
764
  else {
@@ -639,7 +778,13 @@ class Execution {
639
778
  if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
640
779
  if (nested.some((step) => step.cdp === 'Page.enable'))
641
780
  await this.poll(() => this.firstLoad(child, resumed), 5000);
642
- })().catch((error) => this.errors.push(String(error.message ?? error)));
781
+ })().catch((error) =>
782
+ this.errors.push(
783
+ workerRow
784
+ ? `adopt ${info?.type ?? 'target'} ${child}: ${String(error.message ?? error)}`
785
+ : String(error.message ?? error)
786
+ )
787
+ );
643
788
  this.childRuns.set(child, started);
644
789
  this.pending.push(started);
645
790
  }
@@ -704,7 +849,11 @@ class Execution {
704
849
  }
705
850
 
706
851
  childMatches(info) {
707
- if (this.childWhere.type && info?.type !== this.childWhere.type) return false;
852
+ if (this.childWhere.types !== undefined) {
853
+ if (!this.childWhere.types.includes(info?.type)) return false;
854
+ } else if (this.childWhere.type !== undefined) {
855
+ if (this.childWhere.type !== info?.type) return false;
856
+ }
708
857
  if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
709
858
  if (this.childWhere.urlPrefix !== undefined)
710
859
  return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
@@ -751,6 +900,7 @@ class Execution {
751
900
  'redirectSecret',
752
901
  'unparseable',
753
902
  'errorPage',
903
+ 'closeProtectedShape',
754
904
  'extensionWorld',
755
905
  'extensionFrame',
756
906
  'frameRead',
@@ -759,9 +909,14 @@ class Execution {
759
909
  if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
760
910
  if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
761
911
  if (
762
- ['errorPage', 'redirectSecret', 'extensionFrame', 'frameRead', 'bfcacheRestore'].includes(
763
- probe
764
- ) &&
912
+ [
913
+ 'errorPage',
914
+ 'closeProtectedShape',
915
+ 'redirectSecret',
916
+ 'extensionFrame',
917
+ 'frameRead',
918
+ 'bfcacheRestore',
919
+ ].includes(probe) &&
765
920
  out.ok &&
766
921
  out.result?.incomplete
767
922
  )
@@ -791,7 +946,7 @@ class Execution {
791
946
  Boolean(childSession) && !out.ok && this.detached(childSession)
792
947
  );
793
948
  if ((step.isolatedWorld || step.observer) && !out.ok)
794
- this.notes.push(
949
+ this.note(
795
950
  `${step.isolatedWorld ? `isolatedWorld ${step.isolatedWorld}` : `observer ${step.observer}`}: ${out.error?.message ?? 'failed'}`
796
951
  );
797
952
  this.options.debug?.({ step, out, side: this.side });
@@ -826,7 +981,7 @@ class Execution {
826
981
  return Boolean(next);
827
982
  }, browserUiMs);
828
983
  if (!next) return undefined;
829
- this.notes.push(
984
+ this.note(
830
985
  `{browserUi} ${vanished} was gone when ${step.cdp} reached it; retried with ${next.targetId}`
831
986
  );
832
987
  this.vars.browserUi = next.targetId;
@@ -857,48 +1012,14 @@ class Execution {
857
1012
  return;
858
1013
  }
859
1014
  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
- );
1015
+ if (
1016
+ step.redirectSecret ||
1017
+ step.errorPage ||
1018
+ step.closeProtectedShape ||
1019
+ step.spawnWorker ||
1020
+ step.probeSessions
1021
+ )
1022
+ return this.stepProbe(step, session);
902
1023
  if (step.bfcacheRestore)
903
1024
  return outcome(
904
1025
  bfcacheRestore({
@@ -1009,12 +1130,10 @@ class Execution {
1009
1130
 
1010
1131
  async isolatedWorld(step) {
1011
1132
  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
- });
1133
+ const attached = await this.readerFrameTree(targetId, 'isolatedWorld page');
1134
+ if (!attached.ok) throw new Error('isolatedWorld attach failed');
1135
+ const { sessionId, frameTree } = attached;
1016
1136
  try {
1017
- const { frameTree } = await this.reader.send('Page.getFrameTree', {}, sessionId);
1018
1137
  const { executionContextId } = await this.reader.send(
1019
1138
  'Page.createIsolatedWorld',
1020
1139
  { frameId: frameTree.frame.id, worldName: step.isolatedWorld },
@@ -1041,6 +1160,248 @@ class Execution {
1041
1160
  }
1042
1161
  }
1043
1162
 
1163
+ stepProbe(step, session) {
1164
+ if (step.redirectSecret || step.errorPage) return this.originProbe(step, session);
1165
+ if (step.closeProtectedShape) return this.runCloseProtectedShape(step);
1166
+ if (step.spawnWorker) return this.spawnWorker(step);
1167
+ return this.probeSessions(step);
1168
+ }
1169
+
1170
+ async originProbe(step, session) {
1171
+ if (step.redirectSecret) {
1172
+ this.internalSessions.add(session);
1173
+ try {
1174
+ return await outcome(
1175
+ redirectSecret({
1176
+ client: this.client,
1177
+ reader: this.reader,
1178
+ session,
1179
+ targetId: this.pageTarget,
1180
+ ordinary: this.vars.ordinary,
1181
+ tag: this.vars.tag,
1182
+ fixture: this.options.fixture,
1183
+ mode: step.redirectSecret,
1184
+ side: this.side,
1185
+ settleScale: this.scale,
1186
+ controls: (this.clients.redirectSecretControls ??= {}),
1187
+ internalSessions: this.internalSessions,
1188
+ })
1189
+ );
1190
+ } finally {
1191
+ this.internalSessions.delete(session);
1192
+ }
1193
+ }
1194
+ return outcome(
1195
+ protectedErrorPage({
1196
+ client: this.client,
1197
+ reader: this.reader,
1198
+ session,
1199
+ targetId: this.pageTarget,
1200
+ ordinary: this.vars.ordinary,
1201
+ protectedOrigin: this.vars.seven,
1202
+ tag: this.vars.tag,
1203
+ internalSessions: this.internalSessions,
1204
+ mode: step.errorPage,
1205
+ side: this.side,
1206
+ secret: step.secret,
1207
+ discovery: supported(['discovery'], this.options.capabilities),
1208
+ notes: this.notes,
1209
+ budget: this.bound(5000),
1210
+ })
1211
+ );
1212
+ }
1213
+
1214
+ runCloseProtectedShape(step) {
1215
+ return outcome(
1216
+ closeProtectedShape({
1217
+ client: this.client,
1218
+ reader: this.reader,
1219
+ ordinary: this.vars.ordinary,
1220
+ protectedOrigin: this.vars.target,
1221
+ shape: step.closeProtectedShape,
1222
+ tag: this.vars.tag,
1223
+ internalSessions: this.internalSessions,
1224
+ notes: this.notes,
1225
+ budget: this.bound(5000),
1226
+ releaseSlow: this.options.fixture.releaseSlow,
1227
+ })
1228
+ );
1229
+ }
1230
+
1231
+ spawnWorkerKind() {
1232
+ return allSteps([...(this.vector.setup ?? []), ...(this.vector.trigger ?? [])]).find(
1233
+ (step) => step.spawnWorker
1234
+ )?.spawnWorker;
1235
+ }
1236
+
1237
+ async ensurePriorWorkersGone(kind) {
1238
+ if (this.side !== 'guarded') return;
1239
+ const type = workerTypeForKind(kind);
1240
+ if (!persistentWorkerTypes.has(type)) return;
1241
+ const prior = this.clients.priorWorker;
1242
+ const leftover = await leftoverPersistentWorker(this.reader ?? this.client, {
1243
+ type,
1244
+ ...(prior?.url || prior?.targetId ? { url: prior?.url, targetId: prior?.targetId } : {}),
1245
+ });
1246
+ if (leftover)
1247
+ throw new Error(
1248
+ `leftover ${type} still present before guarded spawn: ${leftover.url ?? leftover.targetId}`
1249
+ );
1250
+ }
1251
+
1252
+ async spawnWorker(step) {
1253
+ if (step.as) this.eventMarks.set(step.as, this.client.events.length);
1254
+ const pageUrl = expand(step.url ?? '{target}/doc?n=host-{tag}', this.vars);
1255
+ const hostUrl = step.frame ? expand(step.frame, this.vars) : undefined;
1256
+ await this.ensurePriorWorkersGone(step.spawnWorker);
1257
+ const out = await outcome(
1258
+ protectedWorker({
1259
+ reader: this.reader,
1260
+ kind: step.spawnWorker,
1261
+ pageUrl,
1262
+ hostUrl,
1263
+ tag: this.vars.tag,
1264
+ context: this.vars.context,
1265
+ notes: this.notes,
1266
+ budget: this.bound(5000),
1267
+ })
1268
+ );
1269
+ if (out.ok && out.result?.browserContextId) {
1270
+ this.contexts.push(out.result.browserContextId);
1271
+ this.vars.context = out.result.browserContextId;
1272
+ }
1273
+ if (out.ok && out.result?.pageTargetId) this.targets.push(out.result.pageTargetId);
1274
+ if (out.ok && out.result?.workerTargetId) {
1275
+ this.vars.workerTarget = out.result.workerTargetId;
1276
+ this.targets.push(out.result.workerTargetId);
1277
+ }
1278
+ if (out.ok && out.result?.workerUrl && this.side === 'control')
1279
+ this.clients.priorWorker = {
1280
+ type: out.result.type,
1281
+ url: out.result.workerUrl,
1282
+ targetId: out.result.workerTargetId,
1283
+ };
1284
+ if (!out.ok) this.errors.push(`spawnWorker: ${out.error?.message ?? 'probe failed'}`);
1285
+ if (out.ok && out.result?.incomplete)
1286
+ this.incomplete.push(`spawnWorker: ${out.result.incomplete}`);
1287
+ return out;
1288
+ }
1289
+
1290
+ probeSessionIds(step) {
1291
+ const mark = this.eventMarks.get(step.probeSessions.since) ?? this.eventStart;
1292
+ const sessions = [];
1293
+ for (const event of this.client.events.slice(mark)) {
1294
+ if (event.method === 'Target.attachedToTarget' && event.params?.sessionId)
1295
+ sessions.push(event.params.sessionId);
1296
+ }
1297
+ for (const name of step.probeSessions.attach ?? ['attach']) {
1298
+ const sessionId = this.calls[name]?.result?.sessionId;
1299
+ if (sessionId) sessions.push(sessionId);
1300
+ }
1301
+ return sessions;
1302
+ }
1303
+
1304
+ async probeSessions(step) {
1305
+ const out = await outcome(
1306
+ probeWorkerSessions({
1307
+ client: this.client,
1308
+ sessions: this.probeSessionIds(step),
1309
+ path: expand(step.probeSessions.path, this.vars),
1310
+ tag: this.vars.tag,
1311
+ detached: (sessionId) => this.detached(sessionId),
1312
+ })
1313
+ );
1314
+ this.probedSessionIds ??= new Set();
1315
+ for (const sessionId of out.result?.probedIds ?? []) this.probedSessionIds.add(sessionId);
1316
+ if (!out.ok) {
1317
+ this.errors.push(`probeSessions: ${out.error?.message ?? 'probe failed'}`);
1318
+ return out;
1319
+ }
1320
+ if (out.result?.incomplete) {
1321
+ const reason = probeHarnessIncomplete(this.side, out.result.incomplete);
1322
+ if (reason) this.incomplete.push(`probeSessions: ${reason}`);
1323
+ const { incomplete: _drop, ...result } = out.result;
1324
+ return { ...out, result };
1325
+ }
1326
+ return out;
1327
+ }
1328
+
1329
+ probeStep() {
1330
+ return [...(this.vector.setup ?? []), ...(this.vector.trigger ?? [])].find(
1331
+ (entry) => entry.probeSessions
1332
+ );
1333
+ }
1334
+
1335
+ async probeFreshSessions() {
1336
+ const step = this.probeStep();
1337
+ if (!step?.probeSessions) return false;
1338
+ const expanded = expand(step, this.vars);
1339
+ this.probedSessionIds ??= new Set();
1340
+ const fresh = this.probeSessionIds(expanded).filter(
1341
+ (sessionId) => sessionId && !this.probedSessionIds.has(sessionId)
1342
+ );
1343
+ if (!fresh.length) return this.calls.probe?.result?.reachable === true;
1344
+ const out = await probeWorkerSessions({
1345
+ client: this.client,
1346
+ sessions: fresh,
1347
+ path: expand(expanded.probeSessions.path, this.vars),
1348
+ tag: this.vars.tag,
1349
+ detached: (sessionId) => this.detached(sessionId),
1350
+ });
1351
+ for (const sessionId of out.probedIds ?? []) this.probedSessionIds.add(sessionId);
1352
+ const sessions = [
1353
+ ...new Set([...(this.calls.probe?.result?.sessions ?? []), ...(out.probedIds ?? fresh)]),
1354
+ ];
1355
+ if (out.incomplete) {
1356
+ this.calls.probe = {
1357
+ ok: true,
1358
+ result: { ...(this.calls.probe?.result ?? {}), ...out, sessions },
1359
+ };
1360
+ const reason = probeHarnessIncomplete(this.side, out.incomplete);
1361
+ if (reason) {
1362
+ this.incomplete.push(`probeSessions: ${reason}`);
1363
+ return 'incomplete';
1364
+ }
1365
+ }
1366
+ const reachable = out.reachable || this.calls.probe?.result?.reachable === true;
1367
+ this.calls.probe = {
1368
+ ok: true,
1369
+ result: {
1370
+ ...(this.calls.probe?.result ?? {}),
1371
+ reachable,
1372
+ probed: (this.calls.probe?.result?.probed ?? 0) + (out.probed ?? 0),
1373
+ sessions,
1374
+ },
1375
+ };
1376
+ return reachable;
1377
+ }
1378
+
1379
+ async observeProbeReachable(want, check) {
1380
+ if (want !== true && want !== false) return this.check(check);
1381
+ let seen = await this.probeFreshSessions();
1382
+ if (seen === 'incomplete') return this.side === 'control' ? false : 'incomplete';
1383
+ if (seen === true) return true;
1384
+ const met = await this.poll(
1385
+ async () => {
1386
+ if (this.clients.flight?.abandoned) return true;
1387
+ seen = await this.probeFreshSessions();
1388
+ if (seen === 'incomplete') return true;
1389
+ return seen === true;
1390
+ },
1391
+ observeMs,
1392
+ false
1393
+ );
1394
+ if (seen === 'incomplete') return this.side === 'control' ? false : 'incomplete';
1395
+ if (want === true && !met && !this.clients.flight?.abandoned) {
1396
+ if (this.side === 'control') return false;
1397
+ this.incomplete.push(
1398
+ `workerReachable: probe evidence not observed within ${this.bound(observeMs)}ms`
1399
+ );
1400
+ return 'incomplete';
1401
+ }
1402
+ return seen === true;
1403
+ }
1404
+
1044
1405
  async watchPage(step) {
1045
1406
  if (!this.watch) {
1046
1407
  const start = this.reader.events.length;
@@ -1108,7 +1469,7 @@ class Execution {
1108
1469
  async settle(settle) {
1109
1470
  const refused = () => (settle.dependsOn ?? []).some((name) => this.refused[name]);
1110
1471
  const skipped = () => {
1111
- this.notes.push(
1472
+ this.note(
1112
1473
  `settle:skipped-refused ${settle.beacon ? `beacon ${settle.beacon}` : settle.served ? `request ${settle.served}` : settle.title ? `title ${settle.title}` : 'load'}`
1113
1474
  );
1114
1475
  };
@@ -1224,6 +1585,14 @@ class Execution {
1224
1585
  this.contexts.push(this.vars.context);
1225
1586
  await this.seedContext(this.vars.context);
1226
1587
  }
1588
+ if (step.reuseSession) {
1589
+ const reused = reusableAttachSession(step, out, params, this.client.events, this.eventStart);
1590
+ if (reused) {
1591
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, reused));
1592
+ this.note(`Target.attachToTarget reused auto-attach session for ${params.targetId}`);
1593
+ return { ok: true, result: { sessionId: reused, reused: true } };
1594
+ }
1595
+ }
1227
1596
  return out;
1228
1597
  }
1229
1598
 
@@ -1234,6 +1603,27 @@ class Execution {
1234
1603
  });
1235
1604
  }
1236
1605
 
1606
+ namedCallSummaries() {
1607
+ return Object.fromEntries(
1608
+ ['attach', 'spawn', 'targets', 'probe', 'tabs']
1609
+ .filter((name) => this.calls[name])
1610
+ .map((name) => {
1611
+ const out = this.calls[name];
1612
+ return [
1613
+ name,
1614
+ {
1615
+ ok: out.ok,
1616
+ ...(out.result?.sessionId ? { sessionId: true } : {}),
1617
+ ...(out.result?.exists !== undefined ? { exists: out.result.exists } : {}),
1618
+ ...(out.result?.reachable !== undefined ? { reachable: out.result.reachable } : {}),
1619
+ ...(out.result?.incomplete ? { incomplete: out.result.incomplete } : {}),
1620
+ ...(out.error ? { error: String(out.error?.message ?? out.error) } : {}),
1621
+ },
1622
+ ];
1623
+ })
1624
+ );
1625
+ }
1626
+
1237
1627
  async page(step, session, phase) {
1238
1628
  const client = this.actor();
1239
1629
  this.actionEventStart = client.events.length;
@@ -1345,20 +1735,30 @@ class Execution {
1345
1735
  return !!late;
1346
1736
  }
1347
1737
 
1348
- async observe(name, check) {
1349
- const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1350
- name
1351
- ];
1738
+ observeEarly(name, check, want) {
1739
+ if (name === 'workerReachable' && this.probeStep())
1740
+ return this.observeProbeReachable(want, check);
1352
1741
  if (check?.forwarded) return this.forwardedCheck(name, check, want);
1353
1742
  if (
1354
1743
  check?.result &&
1355
1744
  (at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
1356
1745
  )
1357
1746
  return 'incomplete';
1747
+ return undefined;
1748
+ }
1749
+
1750
+ async observe(name, check) {
1751
+ const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1752
+ name
1753
+ ];
1754
+ const early = this.observeEarly(name, check, want);
1755
+ if (early !== undefined) return early;
1358
1756
  const plan = steps(this.vector, this.side, this.vars);
1359
1757
  const names = resultNames(check);
1758
+ const absencePoll = this.vector.observe?.[name]?.absencePoll === true;
1360
1759
  const snapshots =
1361
- this.side === 'control' && want === true && !this.clients.flight?.abandoned
1760
+ !this.clients.flight?.abandoned &&
1761
+ ((this.side === 'control' && want === true) || (want === false && absencePoll))
1362
1762
  ? [...plan.setup, ...plan.trigger].filter(
1363
1763
  (step) =>
1364
1764
  names.includes(step.as) &&
@@ -1367,17 +1767,28 @@ class Execution {
1367
1767
  supported(step.requires, this.options.capabilities)
1368
1768
  )
1369
1769
  : [];
1770
+ const hasEvent = (node) =>
1771
+ Boolean(node?.event) ||
1772
+ [...(node?.all ?? []), ...(node?.any ?? []), ...(node?.not ? [node.not] : [])].some(hasEvent);
1370
1773
  const polled =
1371
1774
  check?.observer === 'fixture' || check?.world !== undefined || check?.script !== undefined;
1372
- if (want === false && (check?.world !== undefined || check?.script !== undefined))
1775
+ const shouldPoll =
1776
+ (want === true && (polled || snapshots.length > 0)) ||
1777
+ (want === false && absencePoll && snapshots.length > 0);
1778
+ if (
1779
+ want === false &&
1780
+ !shouldPoll &&
1781
+ (check?.world !== undefined || check?.script !== undefined || hasEvent(check))
1782
+ )
1373
1783
  return this.absenceCheck(check);
1374
- if (want !== true || (!polled && !snapshots.length)) return this.check(check);
1784
+ if (!shouldPoll) return this.check(check);
1375
1785
  let seen = false;
1376
1786
  const met = await this.poll(
1377
1787
  async () => {
1378
1788
  if (this.clients.flight?.abandoned) return true;
1379
1789
  seen = await this.check(check);
1380
1790
  if (seen === 'unwatched' || seen === 'incomplete') return true;
1791
+ if (want === false && seen === true) return true;
1381
1792
  if (seen !== true && snapshots.length) {
1382
1793
  await this.execute(snapshots, undefined, 'observation');
1383
1794
  if (this.clients.flight?.abandoned) return true;
@@ -1390,7 +1801,7 @@ class Execution {
1390
1801
  snapshots.length > 0
1391
1802
  );
1392
1803
  if (seen === 'unwatched' || seen === 'incomplete') return seen;
1393
- if ((!met || this.clients.flight?.abandoned) && snapshots.length) {
1804
+ if (want === true && (!met || this.clients.flight?.abandoned) && snapshots.length) {
1394
1805
  this.incomplete.push(
1395
1806
  `${name}: snapshot evidence not observed within ${this.bound(observeMs)}ms`
1396
1807
  );
@@ -1675,6 +2086,7 @@ class Execution {
1675
2086
 
1676
2087
  async run() {
1677
2088
  this.requestStart = (await this.options.fixture.log()).length;
2089
+ this.installClientTrace();
1678
2090
  let pumping;
1679
2091
  try {
1680
2092
  await this.seed();
@@ -1697,6 +2109,7 @@ class Execution {
1697
2109
  side: this.side,
1698
2110
  });
1699
2111
  const { live, liveWhy } = await this.liveness(unseen, incomplete);
2112
+ if (this.errors.length) this.noteClientTrace();
1700
2113
  return {
1701
2114
  observed,
1702
2115
  live,
@@ -1705,6 +2118,7 @@ class Execution {
1705
2118
  notes: this.notes,
1706
2119
  incomplete: this.incomplete,
1707
2120
  unsupported: this.unsupported,
2121
+ calls: this.namedCallSummaries(),
1708
2122
  evidence: Object.fromEntries(
1709
2123
  Object.entries(this.calls)
1710
2124
  .filter(
@@ -1713,13 +2127,25 @@ class Execution {
1713
2127
  .map(([name, out]) => [name, out.result.diagnostics ?? out.result])
1714
2128
  ),
1715
2129
  };
2130
+ } catch (error) {
2131
+ this.noteClientTrace();
2132
+ throw error;
1716
2133
  } finally {
1717
2134
  this.stopped = true;
1718
2135
  await pumping;
1719
- if (this.watch)
1720
- await outcome(
1721
- this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
1722
- );
2136
+ await this.cleanupLeg();
2137
+ }
2138
+ }
2139
+
2140
+ async cleanupLeg() {
2141
+ if (this.watch)
2142
+ await outcome(
2143
+ this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
2144
+ );
2145
+ if (this.spawnWorkerKind()) {
2146
+ await Promise.allSettled(this.pending);
2147
+ await this.cleanupSpawnedWorkerLeg();
2148
+ } else {
1723
2149
  for (const session of this.attachSettings.keys())
1724
2150
  await outcome(
1725
2151
  this.client.send(
@@ -1730,22 +2156,48 @@ class Execution {
1730
2156
  );
1731
2157
  for (const targetId of new Set(this.targets))
1732
2158
  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
- }
1748
2159
  }
2160
+ for (const browserContextId of new Set(this.contexts))
2161
+ await outcome(
2162
+ (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
2163
+ );
2164
+ this.contexts = [];
2165
+ if (!this.spawnWorkerKind()) await Promise.allSettled(this.pending);
2166
+ if (
2167
+ this.side === 'guarded' &&
2168
+ this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
2169
+ ) {
2170
+ const violation = discoveryViolation(
2171
+ this.client.events.slice(this.eventStart),
2172
+ supported(['discovery'], this.options.capabilities)
2173
+ );
2174
+ if (violation) this.errors.push(violation);
2175
+ }
2176
+ }
2177
+
2178
+ async cleanupSpawnedWorkerLeg() {
2179
+ await this.resumeWaiting();
2180
+ await releasePersistentWorkerSessions({
2181
+ client: this.client,
2182
+ reader: this.reader,
2183
+ events: this.client.events,
2184
+ from: this.eventStart,
2185
+ tag: this.vars.tag,
2186
+ targets: this.targets,
2187
+ attachSessions: [...this.attachSettings.keys()],
2188
+ });
2189
+ if (this.reader && this.reader !== this.client)
2190
+ await releasePersistentWorkerSessions({
2191
+ client: this.reader,
2192
+ reader: this.reader,
2193
+ events: this.reader.events,
2194
+ from: 0,
2195
+ tag: this.vars.tag,
2196
+ targets: this.targets,
2197
+ attachSessions: [],
2198
+ });
2199
+ this.targets = [];
2200
+ this.attachSettings.clear();
1749
2201
  }
1750
2202
  }
1751
2203
 
@@ -1764,7 +2216,7 @@ const observerRequired = (check) => {
1764
2216
  const needsObserver = (vector) =>
1765
2217
  vector.control.via === 'unguarded' ||
1766
2218
  allSteps([...(vector.setup ?? []), ...(vector.trigger ?? [])]).some(
1767
- (step) => step.isolatedWorld || step.observer
2219
+ (step) => step.isolatedWorld || step.observer || step.spawnWorker || step.closeProtectedShape
1768
2220
  ) ||
1769
2221
  observerRequired(vector.liveCheck) ||
1770
2222
  Object.values(vector.observe ?? {}).some(
@@ -1870,14 +2322,75 @@ function variables(targets, target, side, slot, index) {
1870
2322
  return vars;
1871
2323
  }
1872
2324
 
2325
+ function sessionTypeOf(client, sessionId) {
2326
+ if (!sessionId) return 'browser';
2327
+ const events = client.events ?? [];
2328
+ for (let i = events.length - 1; i >= 0; i--) {
2329
+ const event = events[i];
2330
+ if (event.method === 'Target.attachedToTarget' && event.params?.sessionId === sessionId)
2331
+ return event.params.targetInfo?.type || 'unknown';
2332
+ }
2333
+ return 'unknown';
2334
+ }
2335
+
2336
+ function boundFrameTree(rawSend, client, params, sessionId) {
2337
+ let timer;
2338
+ const type = sessionTypeOf(client, sessionId);
2339
+ return Promise.race([
2340
+ rawSend('Page.getFrameTree', params, sessionId),
2341
+ new Promise((_, reject) => {
2342
+ timer = setTimeout(
2343
+ () =>
2344
+ reject(
2345
+ new Error(
2346
+ `Page.getFrameTree timed out on ${type} session ${sessionId ?? 'browser'} after 3000ms`
2347
+ )
2348
+ ),
2349
+ 3000
2350
+ );
2351
+ }),
2352
+ ]).finally(() => clearTimeout(timer));
2353
+ }
2354
+
1873
2355
  function tracked(client, calls, name) {
1874
2356
  if (!client || typeof client.send !== 'function') return client;
1875
2357
  let next = 0;
2358
+ const rawSend = client.send.bind(client);
1876
2359
  const send = (method, params, sessionId) => {
1877
2360
  const id = ++next;
1878
2361
  calls.set(id, { name, method, session: sessionId, since: Date.now() });
2362
+ if (!client.__sliccOnSend && !client.__sliccBoundGetFrameTree) {
2363
+ return Promise.resolve()
2364
+ .then(() => client.send(method, params, sessionId))
2365
+ .finally(() => calls.delete(id));
2366
+ }
2367
+ const started = Date.now();
2368
+ const run =
2369
+ method === 'Page.getFrameTree' && client.__sliccBoundGetFrameTree
2370
+ ? () => boundFrameTree(rawSend, client, params, sessionId)
2371
+ : () => rawSend(method, params, sessionId);
1879
2372
  return Promise.resolve()
1880
- .then(() => client.send(method, params, sessionId))
2373
+ .then(run)
2374
+ .then(
2375
+ (result) => {
2376
+ client.__sliccOnSend?.({
2377
+ method,
2378
+ sessionId,
2379
+ ms: Date.now() - started,
2380
+ status: 'ok',
2381
+ });
2382
+ return result;
2383
+ },
2384
+ (error) => {
2385
+ client.__sliccOnSend?.({
2386
+ method,
2387
+ sessionId,
2388
+ ms: Date.now() - started,
2389
+ status: String(error?.message ?? error).slice(0, 200),
2390
+ });
2391
+ throw error;
2392
+ }
2393
+ )
1881
2394
  .finally(() => calls.delete(id));
1882
2395
  };
1883
2396
  return new Proxy(client, {
@@ -2025,12 +2538,27 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2025
2538
  }
2026
2539
  if (legs.control.unsupported === 'trustedEmulation')
2027
2540
  return skipRow(base, 'not-supported:trustedEmulation');
2541
+ const row = finishRow(vector, base, legs, opts);
2542
+ opts.log?.(row);
2543
+ return row;
2544
+ }
2545
+
2546
+ function finishRow(vector, base, legs, opts) {
2547
+ const caps = Array.isArray(opts.capabilities)
2548
+ ? opts.capabilities
2549
+ : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true);
2550
+ const calls = {
2551
+ ...(legs.control.calls && Object.keys(legs.control.calls).length
2552
+ ? { control: legs.control.calls }
2553
+ : {}),
2554
+ ...(legs.guarded.calls && Object.keys(legs.guarded.calls).length
2555
+ ? { guarded: legs.guarded.calls }
2556
+ : {}),
2557
+ };
2028
2558
  const row = {
2029
2559
  ...result(vector, {
2030
2560
  ...base,
2031
- capabilities: Array.isArray(opts.capabilities)
2032
- ? opts.capabilities
2033
- : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true),
2561
+ capabilities: caps,
2034
2562
  control: legs.control.observed,
2035
2563
  guarded: legs.guarded.observed,
2036
2564
  live: legs.control.live && legs.guarded.live,
@@ -2040,6 +2568,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2040
2568
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },
2041
2569
  errors: [...legs.control.errors, ...legs.guarded.errors],
2042
2570
  designDependent: vector.designDependent ?? [],
2571
+ ...(Object.keys(calls).length ? { calls } : {}),
2043
2572
  ...probeEvidence(legs),
2044
2573
  };
2045
2574
  const notes = [
@@ -2052,7 +2581,6 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
2052
2581
  row.control = 'fail';
2053
2582
  row.guarded = 'fail';
2054
2583
  }
2055
- opts.log?.(row);
2056
2584
  return row;
2057
2585
  }
2058
2586