@ai-ecoverse/slicc-browsers 1.5.0 → 1.7.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.
@@ -0,0 +1,1035 @@
1
+ import {
2
+ beacon,
3
+ canary,
4
+ expand,
5
+ hop,
6
+ isRefusal,
7
+ outcome,
8
+ summary as report,
9
+ result,
10
+ steps,
11
+ } from './guard-driver.mjs';
12
+
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ const q = JSON.stringify;
15
+ const at = (value, path = '') =>
16
+ path
17
+ .split('.')
18
+ .filter(Boolean)
19
+ .reduce((v, k) => v?.[k], value);
20
+ const supported = (requires, capabilities) =>
21
+ (requires ?? []).every((c) =>
22
+ Array.isArray(capabilities) ? capabilities.includes(c) : capabilities[c] === true
23
+ );
24
+ const matches = (value, predicate) => {
25
+ if (predicate === null || typeof predicate !== 'object') return value === predicate;
26
+ return Object.entries(predicate).every(([key, want]) => {
27
+ if (key === '$encoded') return value === `${want.prefix ?? ''}${btoa(want.value)}`;
28
+ if (key === '$contains') return typeof value === 'string' && value.includes(want);
29
+ if (key === '$regex')
30
+ return new RegExp(want).test(typeof value === 'object' ? q(value) : String(value ?? ''));
31
+ if (key === '$some') return Array.isArray(value) && value.some((item) => matches(item, want));
32
+ if (key === '$every')
33
+ return Array.isArray(value) && value.length > 0 && value.every((item) => matches(item, want));
34
+ return matches(value?.[key], want);
35
+ });
36
+ };
37
+
38
+ const observedValue = (out, path) => {
39
+ const value = at(out, path);
40
+ if (
41
+ /timeout|timed out/i.test(String(out?.error?.message ?? out?.error ?? '')) ||
42
+ out?.result?.exceptionDetails ||
43
+ value === 'timeout'
44
+ )
45
+ throw new Error('Observation did not complete');
46
+ return value;
47
+ };
48
+
49
+ const address = (value) =>
50
+ typeof value === 'string' ? value : hop(value.hops.slice(value.index ?? 0), value.to);
51
+ const stateCheck = (check) =>
52
+ check?.state ||
53
+ [...(check?.all ?? []), ...(check?.any ?? []), ...(check?.not ? [check.not] : [])]
54
+ .map(stateCheck)
55
+ .find(Boolean);
56
+
57
+ function pageExpression(step, url) {
58
+ let expression;
59
+ switch (step.page) {
60
+ case 'eval':
61
+ expression = step.expression;
62
+ break;
63
+ case 'href':
64
+ expression = `location.href=${q(url)}`;
65
+ break;
66
+ case 'replace':
67
+ expression = `location.replace(${q(url)})`;
68
+ break;
69
+ case 'open':
70
+ expression = `(() => { window.open(${q(url)}); return 'opened'; })()`;
71
+ break;
72
+ case 'iframe':
73
+ expression = `(() => { const f=document.createElement('iframe'); f.src=${q(url)}; document.body.append(f); return true; })()`;
74
+ break;
75
+ case 'form':
76
+ case 'post':
77
+ expression = `(() => { const u=new URL(${q(url)}); const f=document.createElement('form'); f.method=${q(step.page === 'post' ? 'POST' : 'GET')}; f.action=u.origin+u.pathname${step.page === 'post' ? '+u.search' : ''}; for (const [name,value] of u.searchParams) { const i=document.createElement('input'); i.type='hidden'; i.name=name; i.value=value; f.append(i); } document.body.append(f); f.submit(); return true; })()`;
78
+ break;
79
+ case 'fetch':
80
+ expression = `fetch(${q(url)},${q({ credentials: step.credentials ?? 'same-origin' })}).then(async r=>${step.status ? "r.status+' '+" : ''}(await r.text()),()=> 'error')`;
81
+ break;
82
+ case 'xhr':
83
+ expression = `new Promise(resolve=>{const x=new XMLHttpRequest();x.open('GET',${q(url)});x.onload=()=>resolve(${step.status ? "x.status+' '+" : ''}x.responseText);x.onerror=()=>resolve('error');x.send();})`;
84
+ break;
85
+ case 'image':
86
+ expression = `new Promise(resolve=>{const i=new Image();i.onload=()=>resolve('width '+i.naturalWidth);i.onerror=()=>resolve('error');i.src=${q(url)};})`;
87
+ break;
88
+ default:
89
+ throw new Error(`Unknown page kind: ${step.page}`);
90
+ }
91
+ return expression;
92
+ }
93
+
94
+ class Execution {
95
+ constructor(vector, side, options, clients, vars, delay) {
96
+ Object.assign(this, { vector, side, options, clients, vars, delay });
97
+ this.client =
98
+ side === 'control' && vector.control.via === 'unguarded' ? clients.unguarded : clients.client;
99
+ this.reader = clients.unguarded;
100
+ this.calls = {};
101
+ this.refused = {};
102
+ this.sessions = {};
103
+ this.cleanupEventStart = undefined;
104
+ this.targets = [];
105
+ this.contexts = [];
106
+ this.pending = [];
107
+ this.errors = [];
108
+ this.answers = [];
109
+ this.attachSettings = new Map();
110
+ this.childSteps = [];
111
+ this.processed = new Set();
112
+ this.eventStart = this.client.events.length;
113
+ this.actionEventStart = this.eventStart;
114
+ this.requestStart = undefined;
115
+ this.trace = [];
116
+ this.stopped = false;
117
+ }
118
+
119
+ session(kind) {
120
+ if (kind === 'browser') return undefined;
121
+ return this.sessions[kind === 'child' ? 'child' : 'page'];
122
+ }
123
+
124
+ async evaluate(expression, session = this.sessions.page, client = this.client) {
125
+ return outcome(
126
+ client.send(
127
+ 'Runtime.evaluate',
128
+ { expression, awaitPromise: true, returnByValue: true, userGesture: true },
129
+ session
130
+ )
131
+ );
132
+ }
133
+
134
+ async create(url, context = this.vars.context, client = this.client, background = true) {
135
+ const { targetId } = await client.send('Target.createTarget', {
136
+ url: 'about:blank',
137
+ background,
138
+ ...(context ? { browserContextId: context } : {}),
139
+ });
140
+ this.targets.push(targetId);
141
+ this.pageTarget = targetId;
142
+ this.vars.pageTarget = targetId;
143
+ const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
144
+ await client.send('Page.enable', {}, sessionId);
145
+ await outcome(client.send('Network.enable', {}, sessionId));
146
+ if (url !== 'about:blank') await this.navigate(client, sessionId, url);
147
+ return { targetId, sessionId };
148
+ }
149
+
150
+ async navigate(client, session, url) {
151
+ const start = client.events.length;
152
+ const out = await outcome(client.send('Page.navigate', { url }, session));
153
+ if (out.ok && !out.result.errorText)
154
+ await this.poll(
155
+ () =>
156
+ client.events
157
+ .slice(start)
158
+ .some((event) => event.method === 'Page.loadEventFired' && event.sessionId === session),
159
+ 3000
160
+ );
161
+ return out;
162
+ }
163
+
164
+ async poll(predicate, ms = 1500) {
165
+ const deadline = Date.now() + ms;
166
+ do {
167
+ if (await predicate()) return true;
168
+ await sleep(25);
169
+ } while (Date.now() < deadline);
170
+ return false;
171
+ }
172
+
173
+ async prepareState(spec) {
174
+ if (!spec || !this.reader) return;
175
+ const state = expand(spec, this.vars);
176
+ await this.reader.send('Storage.setCookies', {
177
+ cookies: [{ name: state.cookie, value: state.value, url: state.origin }],
178
+ ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
179
+ });
180
+ if (!state.storage) return;
181
+ const { targetId } = await this.reader.send('Target.createTarget', {
182
+ url: 'about:blank',
183
+ ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
184
+ });
185
+ this.canaryTarget = targetId;
186
+ this.targets.push(targetId);
187
+ const { sessionId } = await this.reader.send('Target.attachToTarget', {
188
+ targetId,
189
+ flatten: true,
190
+ });
191
+ this.canarySession = sessionId;
192
+ await this.reader.send('Page.enable', {}, sessionId);
193
+ await this.navigate(
194
+ this.reader,
195
+ sessionId,
196
+ `${state.origin}/canary/${this.vars.tag}?value=${encodeURIComponent(state.value)}`
197
+ );
198
+ const planted = await this.evaluate(canary.plant(state.value), sessionId, this.reader);
199
+ if (!planted.ok || planted.result?.result?.value !== true)
200
+ throw new Error('Canary seeding failed');
201
+ }
202
+
203
+ async readState(state) {
204
+ const { cookies } = await this.reader.send(
205
+ 'Storage.getCookies',
206
+ this.vars.context ? { browserContextId: this.vars.context } : {}
207
+ );
208
+ const cookie = cookies.find(
209
+ (entry) =>
210
+ entry.name === state.cookie &&
211
+ entry.domain.replace(/^\./, '') === new URL(state.origin).hostname
212
+ );
213
+ if (cookie?.value !== state.value) return false;
214
+ if (!state.storage) return true;
215
+ const read = await this.evaluate(canary.read(), this.canarySession, this.reader);
216
+ return read.ok && canary.untouched(state.value, read.result?.result?.value);
217
+ }
218
+
219
+ async attemptCheck(c) {
220
+ const logs = (await this.options.fixture.log()).slice(this.requestStart);
221
+ if (c.pre) {
222
+ const url = address(c.pre);
223
+ return logs.some((entry) => {
224
+ const request = new URL(entry.url);
225
+ return (
226
+ request.pathname === '/live' &&
227
+ request.searchParams.get('tag') === this.vars.tag &&
228
+ request.searchParams.get('step') === 'pre' &&
229
+ request.searchParams.get('to') === url
230
+ );
231
+ });
232
+ }
233
+ const url = address(c.loaded);
234
+ return logs.some((entry) => {
235
+ const request = new URL(entry.url);
236
+ return (
237
+ entry.url === url ||
238
+ (request.pathname === '/live' &&
239
+ request.origin === new URL(url).origin &&
240
+ request.searchParams.get('tag') === this.vars.tag &&
241
+ request.searchParams.get('to') === url &&
242
+ request.searchParams.get('step') !== 'pre')
243
+ );
244
+ });
245
+ }
246
+
247
+ async blocked(spec, observer) {
248
+ const url = address(spec.url);
249
+ const events = this.client.events.slice(this.eventStart);
250
+ if (observer === 'client') {
251
+ const requests = new Map(
252
+ events
253
+ .filter((event) => event.method === 'Network.requestWillBeSent')
254
+ .map((event) => [event.params.requestId, event.params.request.url])
255
+ );
256
+ return events.some(
257
+ (event) =>
258
+ (event.method === 'Page.frameNavigated' &&
259
+ (!spec.frame ||
260
+ event.params.frame.parentId ||
261
+ event.sessionId === this.sessions.child) &&
262
+ event.params.frame.unreachableUrl === url &&
263
+ event.params.frame.url.startsWith('chrome-error://')) ||
264
+ (event.method === 'Network.loadingFailed' &&
265
+ requests.get(event.params.requestId) === url &&
266
+ (event.params.blockedReason || event.params.errorText === 'net::ERR_BLOCKED_BY_CLIENT'))
267
+ );
268
+ }
269
+ const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
270
+ if (!targetId) return false;
271
+ const attached = await outcome(
272
+ this.reader.send('Target.attachToTarget', { targetId, flatten: true })
273
+ );
274
+ if (!attached.ok) return false;
275
+ try {
276
+ const read = await outcome(
277
+ this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
278
+ );
279
+ const visit = (tree) =>
280
+ Boolean(
281
+ tree &&
282
+ ((tree.frame.unreachableUrl === url && tree.frame.url.startsWith('chrome-error://')) ||
283
+ (tree.childFrames ?? []).some(visit))
284
+ );
285
+ return (
286
+ read.ok &&
287
+ (spec.frame
288
+ ? (read.result.frameTree.childFrames ?? []).some(visit)
289
+ : visit(read.result.frameTree))
290
+ );
291
+ } finally {
292
+ await outcome(
293
+ this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
294
+ );
295
+ }
296
+ }
297
+
298
+ async seed() {
299
+ if (supported(['browserContexts'], this.options.capabilities)) {
300
+ const { browserContextId } = await (this.reader ?? this.client).send(
301
+ 'Target.createBrowserContext',
302
+ {}
303
+ );
304
+ this.vars.context = browserContextId;
305
+ this.contexts.push(browserContextId);
306
+ }
307
+ const all = [...this.vector.setup, ...this.vector.trigger];
308
+ const cookieCalls = all.some(
309
+ (s) => /Cookies?$/.test(s.cdp ?? '') || s.params?.storageTypes?.includes('cookies')
310
+ );
311
+ if (cookieCalls && this.reader) {
312
+ const cookies = [
313
+ ...new Set([this.vars.target, this.vars.plain, this.vars.seven, this.vars.vault]),
314
+ ].flatMap((url) => [
315
+ { name: this.vars.cookie, value: this.vars.tag, url },
316
+ { name: 'del', value: this.vars.tag, url },
317
+ ]);
318
+ await this.reader.send('Storage.setCookies', {
319
+ cookies,
320
+ ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
321
+ });
322
+ }
323
+ await this.prepareState(stateCheck(this.vector.liveCheck));
324
+ }
325
+
326
+ async pump() {
327
+ while (!this.stopped) {
328
+ for (const event of this.client.events.slice(this.eventStart)) {
329
+ if (this.processed.has(event)) continue;
330
+ this.processed.add(event);
331
+ if (event.method === 'Fetch.requestPaused' || event.method === 'Fetch.authRequired') {
332
+ this.pending.push(
333
+ this.answer(event).catch((error) => this.errors.push(String(error.message ?? error)))
334
+ );
335
+ }
336
+ if (
337
+ event.method === 'Target.attachedToTarget' &&
338
+ event.params.targetInfo?.targetId !== this.pageTarget &&
339
+ event.params.targetInfo?.targetId !== this.canaryTarget &&
340
+ event.params.sessionId !== this.sessions.page
341
+ ) {
342
+ const info = event.params.targetInfo;
343
+ if (
344
+ info?.browserContextId &&
345
+ this.vars.context &&
346
+ info.browserContextId !== this.vars.context
347
+ )
348
+ continue;
349
+ this.sessions.child = event.params.sessionId;
350
+ if (info?.targetId) this.targets.push(info.targetId);
351
+ this.pending.push(
352
+ (async () => {
353
+ const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
354
+ if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
355
+ else {
356
+ await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
357
+ const start = this.client.events.length;
358
+ await outcome(
359
+ this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
360
+ );
361
+ await this.poll(
362
+ () =>
363
+ this.client.events
364
+ .slice(start)
365
+ .some(
366
+ (e) =>
367
+ e.method === 'Page.loadEventFired' &&
368
+ e.sessionId === event.params.sessionId
369
+ ),
370
+ 1500
371
+ );
372
+ await this.execute(this.childSteps.slice(reload), event.params.sessionId);
373
+ }
374
+ await outcome(
375
+ this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
376
+ );
377
+ })().catch((error) => this.errors.push(String(error.message ?? error)))
378
+ );
379
+ }
380
+ }
381
+ await sleep(10);
382
+ }
383
+ }
384
+
385
+ async answer(event) {
386
+ const p = event.params;
387
+ const rule = this.answers.find(
388
+ (a) =>
389
+ a.url === p.request.url &&
390
+ (!a.stage || (a.stage === 'Response') === Boolean(p.responseStatusCode)) &&
391
+ (a.cdp === 'Fetch.continueWithAuth') === (event.method === 'Fetch.authRequired')
392
+ );
393
+ if (rule?.hold) return;
394
+ const method =
395
+ rule?.cdp ??
396
+ (event.method === 'Fetch.authRequired'
397
+ ? 'Fetch.continueWithAuth'
398
+ : p.responseStatusCode
399
+ ? 'Fetch.continueResponse'
400
+ : 'Fetch.continueRequest');
401
+ const params =
402
+ rule?.params ??
403
+ (event.method === 'Fetch.authRequired'
404
+ ? { authChallengeResponse: { response: 'CancelAuth' } }
405
+ : {});
406
+ const out = await outcome(
407
+ this.client.send(method, { ...params, requestId: p.requestId }, event.sessionId)
408
+ );
409
+ this.calls[`answer.${p.requestId}.${event.method}`] = out;
410
+ }
411
+
412
+ async execute(list, childSession, phase = 'child') {
413
+ for (const raw of list) {
414
+ const step = expand(raw, this.vars);
415
+ if (!supported(step.requires, this.options.capabilities) || step.text) continue;
416
+ const session = childSession ?? this.session(step.session);
417
+ const out = await this.operation(step, session, phase);
418
+ if (!out) continue;
419
+ this.trace.push({
420
+ phase,
421
+ declared: Boolean(step.refusable),
422
+ session,
423
+ out,
424
+ refused: step.refusable
425
+ ? isRefusal(out, step)
426
+ : /refused/.test(String(out.error?.message ?? out.error)),
427
+ });
428
+ if (step.refusable) this.refused[step.refusal.name] = isRefusal(out, step);
429
+ if (step.as && !out.result?.pending) this.calls[step.as] = out;
430
+ if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
431
+ this.options.debug?.({ step, out, side: this.side });
432
+ }
433
+ }
434
+
435
+ async operation(step, session, phase) {
436
+ if (step.children) {
437
+ this.childSteps = step.children;
438
+ return;
439
+ }
440
+ if (step.answer) {
441
+ this.answers.push(step.answer);
442
+ return;
443
+ }
444
+ if (step.wait !== undefined) {
445
+ await sleep(step.wait);
446
+ return;
447
+ }
448
+ if (step.settle) {
449
+ await this.settle(step.settle);
450
+ return;
451
+ }
452
+ if (step.guess) return this.guess(step.guess, session);
453
+ if (step.cdp) return this.cdp(step, session);
454
+ if (step.page) return this.page(step, session, phase);
455
+ throw new Error('Unknown step kind');
456
+ }
457
+
458
+ async settle(settle) {
459
+ if (settle.variant) await sleep(this.delay);
460
+ if (settle.title && !this.reader) {
461
+ await sleep(settle.ms ?? 1500);
462
+ return;
463
+ }
464
+ await this.poll(async () => {
465
+ if (settle.beacon)
466
+ return (
467
+ (await this.requestCheck({ beacon: settle.beacon }, this.actionRequestStart)) &&
468
+ (!this.actionDestination ||
469
+ (await this.requestCheck({ served: this.actionDestination }, this.actionRequestStart)))
470
+ );
471
+ if (settle.title) return this.check({ title: settle.title, session: settle.session });
472
+ return this.client.events
473
+ .slice(this.actionEventStart)
474
+ .some((event) => event.method === 'Page.loadEventFired');
475
+ }, settle.ms ?? 1500);
476
+ }
477
+
478
+ async guess(guess, session) {
479
+ const pauses = this.client.events
480
+ .slice(this.eventStart)
481
+ .filter((e) => e.method === 'Fetch.requestPaused');
482
+ const ids = guess.between.map(
483
+ (url) => pauses.find((e) => e.params.request.url === url)?.params.requestId
484
+ );
485
+ const parts = ids.map((id) => /^(.*?)(\d+)(\.\d+)$/.exec(id ?? ''));
486
+ const attempts = [];
487
+ if (parts.every(Boolean) && parts[0][1] === parts[1][1] && parts[0][3] === parts[1][3]) {
488
+ for (let n = Number(parts[0][2]) + 1; n < Number(parts[1][2]) && attempts.length < 256; n++) {
489
+ const requestId = `${parts[0][1]}${n}${parts[0][3]}`;
490
+ attempts.push(
491
+ await outcome(this.client.send(guess.cdp, { ...guess.params, requestId }, session))
492
+ );
493
+ }
494
+ }
495
+ await sleep(100);
496
+ return { ok: true, result: attempts };
497
+ }
498
+
499
+ async cdp(step, sessionId) {
500
+ if (step.cdp === 'Target.detachFromTarget') await sleep(this.delay);
501
+ if (step.cdp === 'Target.setAutoAttach' && step.params?.autoAttach === false)
502
+ this.cleanupEventStart = this.client.events.length;
503
+ const params = { ...step.params };
504
+ if (
505
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp) &&
506
+ this.vars.context &&
507
+ !params.browserContextId
508
+ )
509
+ params.browserContextId = this.vars.context;
510
+ const session =
511
+ step.session === 'page-or-browser' &&
512
+ supported(['browserSession'], this.options.capabilities) &&
513
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp)
514
+ ? undefined
515
+ : sessionId;
516
+ const out = await outcome(this.client.send(step.cdp, params, session));
517
+ if (step.cdp === 'Target.setAutoAttach' && out.ok) {
518
+ if (params.autoAttach) this.attachSettings.set(session, true);
519
+ else this.attachSettings.delete(session);
520
+ }
521
+ if (step.cdp === 'Target.createBrowserContext' && out.ok) {
522
+ this.vars.context = out.result.browserContextId;
523
+ this.contexts.push(this.vars.context);
524
+ await this.seedContext(this.vars.context);
525
+ }
526
+ return out;
527
+ }
528
+
529
+ async seedContext(context) {
530
+ await this.reader.send('Storage.setCookies', {
531
+ cookies: [{ name: this.vars.cookie, value: this.vars.tag, url: this.vars.target }],
532
+ browserContextId: context,
533
+ });
534
+ }
535
+
536
+ async page(step, session, phase) {
537
+ this.actionEventStart = this.client.events.length;
538
+ this.actionRequestStart = (await this.options.fixture.log()).length;
539
+ this.actionDestination = step.hops ? step.url : undefined;
540
+ const url = step.hops ? hop(step.hops, step.url) : step.url;
541
+ const pre =
542
+ phase === 'trigger' && url
543
+ ? `try { navigator.sendBeacon(${q(`${this.vars.log}/live?tag=${this.vars.tag}&step=pre&to=`)} + encodeURIComponent(${q(url)})); } catch {}`
544
+ : '';
545
+ if (step.page === 'tab') {
546
+ const tab = await this.create(
547
+ url,
548
+ step.context ?? this.vars.context,
549
+ this.client,
550
+ step.background ?? true
551
+ );
552
+ this.sessions.page = tab.sessionId;
553
+ this.pageTarget = tab.targetId;
554
+ return { ok: true, result: tab };
555
+ }
556
+ if (step.page === 'navigate') {
557
+ if (pre) await this.evaluate(pre, session);
558
+ return this.navigate(this.client, session, url);
559
+ }
560
+ if (step.page === 'framed')
561
+ return this.navigate(
562
+ this.client,
563
+ session,
564
+ `${step.from}/framed?tag=${encodeURIComponent(this.vars.tag)}&url=${encodeURIComponent(url)}&log=${encodeURIComponent(this.vars.log)}`
565
+ );
566
+ let expression = pageExpression(step, url);
567
+ if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
568
+ const promise = this.evaluate(expression, session);
569
+ if (step.background) {
570
+ this.pending.push(
571
+ promise.then((out) => {
572
+ if (typeof step.as === 'string') this.calls[step.as] = out;
573
+ })
574
+ );
575
+ return { ok: true, result: { pending: true } };
576
+ }
577
+ const out = await promise;
578
+ if (step.page === 'open') {
579
+ await this.poll(() => Boolean(this.sessions.child), 1500);
580
+ if (!this.childSteps.length && this.sessions.child)
581
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, this.sessions.child));
582
+ }
583
+ return out;
584
+ }
585
+
586
+ async check(check) {
587
+ const c = expand(check, this.vars);
588
+ if (c.observer === 'unguarded' && !this.reader) return false;
589
+ const events = this.client.events.slice(this.eventStart);
590
+ const evidence = await this.evidence(c);
591
+ if (evidence !== undefined) return evidence;
592
+ if (c.live) return (await outcome(this.reader.send('Target.getTargets', {}))).ok;
593
+ if (c.executed)
594
+ return this.trace.some(
595
+ (entry) => entry.phase === c.executed && (entry.out.ok || entry.refused)
596
+ );
597
+ if (c.all) return (await Promise.all(c.all.map((item) => this.check(item)))).every(Boolean);
598
+ if (c.any) return (await Promise.all(c.any.map((item) => this.check(item)))).some(Boolean);
599
+ if (c.not) return !(await this.check(c.not));
600
+ if (c.event) return events.some((e) => e.method === c.event && matches(e, c.where ?? {}));
601
+ if (c.hops || c.beacon || c.served || c.header) return this.requestCheck(c);
602
+ if (c.title !== undefined || c.expression !== undefined) return this.readCheck(c);
603
+ if (c.delivered)
604
+ return events.some(
605
+ (event) => q(event).includes(c.delivered) || q(event).includes(btoa(c.delivered))
606
+ );
607
+ if (c.compare)
608
+ return (
609
+ c.compare.every((name) => observedValue(this.calls[name], c.path) !== undefined) &&
610
+ observedValue(this.calls[c.compare[0]], c.path) ===
611
+ observedValue(this.calls[c.compare[1]], c.path)
612
+ );
613
+ if (c.result) {
614
+ const value = observedValue(this.calls[c.result], c.path);
615
+ return matches(value, c.equals);
616
+ }
617
+ if (c.cookie)
618
+ return (
619
+ this.calls[c.from]?.result?.cookies?.some(
620
+ (cookie) =>
621
+ cookie.name === c.cookie &&
622
+ (!c.on || cookie.domain.replace(/^\./, '') === new URL(c.on).hostname)
623
+ ) ?? false
624
+ );
625
+ if (c.refused || c.reached) return this.callCheck(c);
626
+ if (c.detached) {
627
+ const session = this.sessions[c.detached];
628
+ return (
629
+ Boolean(session) &&
630
+ (events.some(
631
+ (event) =>
632
+ event.method === 'Target.detachedFromTarget' && event.params.sessionId === session
633
+ ) ||
634
+ this.trace.some(
635
+ (entry) =>
636
+ entry.session === session &&
637
+ !entry.out.ok &&
638
+ /refused|session.*closed|target.*closed|no session/i.test(
639
+ String(entry.out.error?.message ?? entry.out.error)
640
+ )
641
+ ))
642
+ );
643
+ }
644
+ if (c.attached) return this.attachedCheck(c);
645
+ throw new Error('Unknown observation check');
646
+ }
647
+
648
+ async evidence(c) {
649
+ if (c.filtered) {
650
+ const calls =
651
+ c.filtered.from === 'all'
652
+ ? (this.calls.$cdp ?? [])
653
+ : c.filtered.from.map((name) => this.calls[name]);
654
+ return calls.some(
655
+ (out) =>
656
+ out?.ok &&
657
+ Array.isArray(out.result?.cookies) &&
658
+ !out.result.cookies.some(
659
+ (cookie) =>
660
+ cookie.name === c.filtered.cookie &&
661
+ (!c.filtered.on ||
662
+ cookie.domain.replace(/^\./, '') === new URL(c.filtered.on).hostname)
663
+ )
664
+ );
665
+ }
666
+ if (c.registration)
667
+ return this.trace.some(
668
+ (entry) => entry.phase !== 'trigger' && entry.declared && entry.refused
669
+ );
670
+ if (c.state) return this.readState(c.state);
671
+ if (c.blocked) return this.blocked(c.blocked, c.observer);
672
+ if (c.pre || c.loaded) return this.attemptCheck(c);
673
+ }
674
+
675
+ callCheck(c) {
676
+ const names = c.refused ?? c.reached;
677
+ const calls =
678
+ names === 'all'
679
+ ? (this.calls.$cdp ?? [])
680
+ : names.flatMap((name) =>
681
+ this.calls[name]?.result && Array.isArray(this.calls[name].result)
682
+ ? this.calls[name].result
683
+ : [this.calls[name]]
684
+ );
685
+ for (const out of calls) observedValue(out, 'ok');
686
+ return (
687
+ calls.length > 0 &&
688
+ calls.every(
689
+ (out) =>
690
+ out &&
691
+ (c.refused
692
+ ? !out.ok &&
693
+ new RegExp(c.refusal?.match ?? 'refused').test(
694
+ String(out.error?.message ?? out.error)
695
+ )
696
+ : out.ok || !/refused/.test(String(out.error?.message ?? out.error)))
697
+ )
698
+ );
699
+ }
700
+
701
+ attachedCheck(c) {
702
+ const session = this.sessions[c.session ?? 'page'];
703
+ const events = this.client.events.slice(this.eventStart, this.cleanupEventStart);
704
+ if (
705
+ !session ||
706
+ events.some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session)
707
+ )
708
+ return false;
709
+ if (c.session === 'child')
710
+ return events.some(
711
+ (event) =>
712
+ event.method === 'Target.attachedToTarget' &&
713
+ event.params.sessionId === session &&
714
+ (event.params.targetInfo.openerId === this.pageTarget ||
715
+ event.params.targetInfo.parentId === this.pageTarget ||
716
+ event.params.targetInfo.url.startsWith(c.attached))
717
+ );
718
+ return events.some(
719
+ (event) =>
720
+ (event.sessionId === session &&
721
+ event.method === 'Page.frameNavigated' &&
722
+ event.params.frame.url.startsWith(c.attached)) ||
723
+ (event.method === 'Target.attachedToTarget' &&
724
+ event.params.sessionId === session &&
725
+ event.params.targetInfo.url.startsWith(c.attached))
726
+ );
727
+ }
728
+
729
+ async requestCheck(c, start = this.requestStart) {
730
+ const logs = (await this.options.fixture.log()).slice(start);
731
+ if (c.hops)
732
+ return c.hops.every((_, i) => logs.some((entry) => entry.url === hop(c.hops.slice(i), c.to)));
733
+ return logs.some((entry) => {
734
+ const url = new URL(entry.url);
735
+ if (c.beacon)
736
+ return (
737
+ url.pathname === '/beacon' &&
738
+ url.searchParams.get('tag') === this.vars.tag &&
739
+ url.searchParams.get('origin') === c.beacon
740
+ );
741
+ if (c.tagged !== false && !entry.url.includes(this.vars.tag)) return false;
742
+ if (c.origin && url.origin !== c.origin) return false;
743
+ if (c.served)
744
+ return c.served.startsWith('http') ? entry.url === c.served : url.pathname === c.served;
745
+ return (
746
+ (c.on.startsWith('http') ? entry.url === c.on : url.pathname === c.on) &&
747
+ entry.headers[c.header.toLowerCase()] === (c.value ?? this.vars.tag)
748
+ );
749
+ });
750
+ }
751
+
752
+ async readCheck(c) {
753
+ const targetId = c.session === 'child' ? this.targets.at(-1) : this.pageTarget;
754
+ if (!targetId) return false;
755
+ const { sessionId } = await this.reader.send('Target.attachToTarget', {
756
+ targetId,
757
+ flatten: true,
758
+ });
759
+ try {
760
+ const out = await this.evaluate(c.expression ?? 'document.title', sessionId, this.reader);
761
+ return matches(observedValue(out, 'result.result.value'), c.title ?? c.equals);
762
+ } finally {
763
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
764
+ }
765
+ }
766
+
767
+ async run() {
768
+ this.requestStart = (await this.options.fixture.log()).length;
769
+ let pumping;
770
+ try {
771
+ await this.seed();
772
+ pumping = this.pump();
773
+ const plan = steps(this.vector, this.side, {
774
+ ...this.vars,
775
+ context: '{context}',
776
+ log: this.vars.log,
777
+ tag: this.vars.tag,
778
+ });
779
+ await this.execute(plan.setup, undefined, 'setup');
780
+ await this.execute(plan.trigger, undefined, 'trigger');
781
+ await sleep(100);
782
+ const observed = { ...this.refused };
783
+ for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
784
+ if (!supported(observation.requires, this.options.capabilities)) continue;
785
+ if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
786
+ observed[name] = await this.check(observation.check);
787
+ }
788
+ this.options.debug?.({
789
+ events: this.client.events.slice(this.eventStart),
790
+ calls: this.calls,
791
+ requests: await this.options.fixture.log(),
792
+ side: this.side,
793
+ });
794
+ const live = this.side === 'control' ? true : await this.check(this.vector.liveCheck);
795
+ return { observed, live, errors: this.errors };
796
+ } finally {
797
+ this.stopped = true;
798
+ await pumping;
799
+ for (const session of this.attachSettings.keys())
800
+ await outcome(
801
+ this.client.send(
802
+ 'Target.setAutoAttach',
803
+ { autoAttach: false, waitForDebuggerOnStart: false, flatten: true },
804
+ session
805
+ )
806
+ );
807
+ for (const targetId of new Set(this.targets))
808
+ await outcome((this.reader ?? this.client).send('Target.closeTarget', { targetId }));
809
+ for (const browserContextId of new Set(this.contexts))
810
+ await outcome(
811
+ (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
812
+ );
813
+ await Promise.allSettled(this.pending);
814
+ }
815
+ }
816
+ }
817
+
818
+ const observerRequired = (check) => {
819
+ if (check?.observer === 'unguarded') return true;
820
+ if (check?.all) return check.all.some(observerRequired);
821
+ if (check?.any) return check.any.every(observerRequired);
822
+ return check?.not ? observerRequired(check.not) : false;
823
+ };
824
+ const needsObserver = (vector) =>
825
+ vector.control.via === 'unguarded' ||
826
+ observerRequired(vector.liveCheck) ||
827
+ Object.values(vector.observe ?? {}).some((observation) => observerRequired(observation.check));
828
+
829
+ const inactive = (vector, target, capabilities) =>
830
+ vector.skip?.target === target ||
831
+ vector.observable?.[target] === false ||
832
+ !supported(vector.requires, capabilities) ||
833
+ (vector.scope !== 'any' && vector.scope !== capabilities.scope);
834
+
835
+ function variables(targets, target, side, slot, index) {
836
+ const tag = `guard-${Date.now()}-${slot}-${index}-${side}-${Math.random().toString(36).slice(2)}`;
837
+ const vars = {
838
+ ...targets,
839
+ plain: targets.plain ?? targets.ordinary,
840
+ cross: targets.cross ?? targets.client,
841
+ log: targets.log ?? targets.ordinary,
842
+ tag,
843
+ cookie: `canary-${tag}`,
844
+ };
845
+ vars.target = side === 'control' ? vars.plain : targets[target];
846
+ vars.targetHost = new URL(vars.target).hostname;
847
+ vars.responseOrigin = vars.target === vars.ordinary ? '*' : vars.ordinary;
848
+ vars.own ??= vars.client;
849
+ vars.beacon = beacon(vars.log, tag);
850
+ return vars;
851
+ }
852
+
853
+ async function executeRow(job, opts, clients, set, slot, index) {
854
+ const { vector, target, variant } = job;
855
+ const base = {
856
+ id: vector.id,
857
+ target,
858
+ variant,
859
+ transport: clients.client?.transport ?? 'cdp',
860
+ via: vector.control.via,
861
+ transportVariant: clients.transportVariant === true,
862
+ };
863
+ if (vector.kind === 'text')
864
+ return {
865
+ ...base,
866
+ control: 'not-run',
867
+ guarded: 'not-run',
868
+ live: 'not-run',
869
+ appliesTo: vector.appliesTo ?? [],
870
+ };
871
+ if (inactive(vector, target, opts.capabilities))
872
+ return { ...base, control: 'skip', guarded: 'skip', live: 'skip', reason: 'not-applicable' };
873
+ if (needsObserver(vector) && !clients.unguarded)
874
+ return {
875
+ ...base,
876
+ control: 'skip',
877
+ guarded: 'skip',
878
+ live: 'skip',
879
+ reason: 'not-supported:unguarded-observer',
880
+ };
881
+ const legs = {};
882
+ for (const side of ['control', 'guarded']) {
883
+ try {
884
+ const vars = variables(opts.targets, target, side, slot, index);
885
+ legs[side] = await new Execution(
886
+ vector,
887
+ side,
888
+ opts,
889
+ clients,
890
+ vars,
891
+ set.variants[variant]
892
+ ).run();
893
+ } catch (error) {
894
+ legs[side] = { observed: {}, live: false, errors: [String(error.message ?? error)] };
895
+ }
896
+ }
897
+ const row = {
898
+ ...result(vector, {
899
+ ...base,
900
+ capabilities: Array.isArray(opts.capabilities)
901
+ ? opts.capabilities
902
+ : Object.keys(opts.capabilities).filter((k) => opts.capabilities[k] === true),
903
+ control: legs.control.observed,
904
+ guarded: legs.guarded.observed,
905
+ live: legs.control.live && legs.guarded.live,
906
+ }),
907
+ transportVariant: base.transportVariant,
908
+ observations: { control: legs.control.observed, guarded: legs.guarded.observed },
909
+ errors: [...legs.control.errors, ...legs.guarded.errors],
910
+ designDependent: vector.designDependent ?? [],
911
+ };
912
+ if (row.errors.length) {
913
+ row.control = 'fail';
914
+ row.guarded = 'fail';
915
+ }
916
+ opts.log?.(row);
917
+ return row;
918
+ }
919
+
920
+ function validate(set, opts) {
921
+ if (!opts.fixture?.log) throw new TypeError('fixture.log is required');
922
+ for (const vector of set.vectors)
923
+ for (const [name, observation] of Object.entries(vector.observe ?? {}))
924
+ if (!observation.check) throw new TypeError(`${vector.id}: ${name} lacks a check`);
925
+ for (const vector of set.vectors)
926
+ if (vector.kind === 'cdp' && !vector.liveCheck)
927
+ throw new TypeError(`${vector.id}: lacks liveCheck`);
928
+ const invalidLive = (check) =>
929
+ Boolean(check?.live || check?.executed || check?.on === 'control') ||
930
+ [...(check?.all ?? []), ...(check?.any ?? []), ...(check?.not ? [check.not] : [])].some(
931
+ invalidLive
932
+ );
933
+ for (const vector of set.vectors)
934
+ if (vector.kind === 'cdp' && invalidLive(vector.liveCheck))
935
+ throw new TypeError(`${vector.id}: liveCheck needs guarded attempt evidence`);
936
+ const variants = Array.isArray(opts.variant) ? opts.variant : [opts.variant];
937
+ for (const name of variants)
938
+ if (!(name in set.variants)) throw new TypeError(`Unknown variant: ${name}`);
939
+ if (!Number.isInteger(opts.parallel) || opts.parallel < 1)
940
+ throw new TypeError('parallel must be a positive integer');
941
+ return variants;
942
+ }
943
+
944
+ export async function run(set, options) {
945
+ const opts = { capabilities: {}, variant: Object.keys(set.variants), parallel: 1, ...options };
946
+ const variants = validate(set, opts);
947
+ const jobs = set.vectors.flatMap((vector) =>
948
+ vector.targets.flatMap((target) => variants.map((variant) => ({ vector, target, variant })))
949
+ );
950
+ const results = new Array(jobs.length);
951
+ let next = 0;
952
+ let serial = Promise.resolve();
953
+ const worker = async (slot) => {
954
+ for (;;) {
955
+ const index = next++;
956
+ if (index >= jobs.length) break;
957
+ const job = jobs[index];
958
+ const made = opts.clientFactory
959
+ ? await opts.clientFactory({
960
+ slot,
961
+ variant: job.variant,
962
+ detachDelay: set.variants[job.variant],
963
+ vector: job.vector,
964
+ target: job.target,
965
+ })
966
+ : opts.client;
967
+ const clients =
968
+ typeof made?.send === 'function'
969
+ ? {
970
+ client: made,
971
+ unguarded: opts.unguarded ?? made.unguarded,
972
+ transportVariant: made.transportVariant,
973
+ }
974
+ : (made ?? { client: undefined, unguarded: opts.unguarded });
975
+ clients.unguarded ??= opts.unguarded;
976
+ try {
977
+ const stateful = [...job.vector.setup, ...job.vector.trigger].some((s) =>
978
+ /Cookie|Storage|IndexedDB|CacheStorage/.test(s.cdp ?? '')
979
+ );
980
+ if (stateful && !supported(['browserContexts'], opts.capabilities)) {
981
+ const previous = serial;
982
+ let done;
983
+ serial = new Promise((resolve) => {
984
+ done = resolve;
985
+ });
986
+ await previous;
987
+ try {
988
+ results[index] = await executeRow(job, opts, clients, set, slot, index);
989
+ } finally {
990
+ done();
991
+ }
992
+ } else results[index] = await executeRow(job, opts, clients, set, slot, index);
993
+ } finally {
994
+ await clients.close?.();
995
+ }
996
+ }
997
+ };
998
+ const width = opts.clientFactory ? opts.parallel : 1;
999
+ await Promise.all(Array.from({ length: width }, (_, slot) => worker(slot)));
1000
+ return { results, summary: (metadata = {}) => summary({ set, results, ...metadata }) };
1001
+ }
1002
+
1003
+ export function summary({ set, results, guard = 'reference', sha = 'unknown' }) {
1004
+ const value = report({
1005
+ set: {
1006
+ ...set,
1007
+ variants: Object.fromEntries(
1008
+ [...new Set(results.map((r) => r.variant))].map((v) => [v, set.variants[v]])
1009
+ ),
1010
+ },
1011
+ results,
1012
+ guard,
1013
+ sha,
1014
+ });
1015
+ const observerSkipped = results.filter(
1016
+ (row) => row.reason === 'not-supported:unguarded-observer'
1017
+ ).length;
1018
+ return {
1019
+ ...value,
1020
+ pass: value.pass && observerSkipped === 0,
1021
+ line: observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line,
1022
+ observerSkipped,
1023
+ counts: Object.fromEntries(
1024
+ ['control', 'guarded', 'live'].map((side) => [
1025
+ side,
1026
+ Object.fromEntries(
1027
+ ['pass', 'fail', 'skip', 'not-run'].map((status) => [
1028
+ status,
1029
+ results.filter((r) => r[side] === status).length,
1030
+ ])
1031
+ ),
1032
+ ])
1033
+ ),
1034
+ };
1035
+ }