@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.
- package/README.md +234 -5
- package/fixture-server.mjs +126 -0
- package/guard-driver.mjs +213 -0
- package/guard-runner.mjs +1035 -0
- package/guard-vectors.json +9198 -0
- package/package.json +21 -7
- package/schema/guard-vectors.schema.json +1687 -0
package/guard-runner.mjs
ADDED
|
@@ -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
|
+
}
|