jevway 0.0.0-stage → 0.1.2

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.
Files changed (43) hide show
  1. package/INSTALL-FOR-AGENTS.md +138 -0
  2. package/LICENSE +11 -0
  3. package/LICENSE.jev-ultrafast +21 -0
  4. package/README.md +218 -2
  5. package/dist/browser/bot-wall.js +105 -0
  6. package/dist/browser/cdp.js +112 -0
  7. package/dist/browser/chrome.js +147 -0
  8. package/dist/browser/devices.js +20 -0
  9. package/dist/browser/frames.js +516 -0
  10. package/dist/browser/snapshot.js +611 -0
  11. package/dist/browser/tab.js +938 -0
  12. package/dist/browser/words.js +51 -0
  13. package/dist/cli.js +112 -0
  14. package/dist/jev/action-space.js +48 -0
  15. package/dist/jev/box.js +58 -0
  16. package/dist/jev/decide.js +405 -0
  17. package/dist/jev/instructions.js +67 -0
  18. package/dist/jev/model.js +189 -0
  19. package/dist/jev/replay.js +213 -0
  20. package/dist/jev/secrets.js +60 -0
  21. package/dist/journeys/diagnostics.js +85 -0
  22. package/dist/journeys/journey-doc.js +50 -0
  23. package/dist/journeys/journey-session.js +503 -0
  24. package/dist/journeys/pick-next-move.js +59 -0
  25. package/dist/journeys/render-viewer.js +153 -0
  26. package/dist/journeys/screen-boxes.js +49 -0
  27. package/dist/journeys/screen-judgments.js +149 -0
  28. package/dist/mcp.js +361 -0
  29. package/dist/setup/doctor-command.js +81 -0
  30. package/dist/setup/doctor.js +163 -0
  31. package/dist/setup/env-file.js +50 -0
  32. package/dist/task/checks.js +156 -0
  33. package/dist/task/evidence.js +96 -0
  34. package/dist/task/hosts.js +91 -0
  35. package/dist/task/look.js +17 -0
  36. package/dist/task/recording-page.js +310 -0
  37. package/dist/task/recording.js +232 -0
  38. package/dist/task/run-limit.js +66 -0
  39. package/dist/task/run-task.js +1607 -0
  40. package/dist/task/start-task.js +91 -0
  41. package/dist/task/upload-files.js +43 -0
  42. package/package.json +80 -4
  43. package/skill/jevway/SKILL.md +73 -0
@@ -0,0 +1,516 @@
1
+ import { centerOf, contains } from '../jev/box.js';
2
+ import { CdpError } from './cdp.js';
3
+ // jevway: every frame is read with the same reader (snapshot.js), each in an isolated world of its own, so ids, guards and
4
+ // the input counter live where the frame's elements live. The main frame keeps the main world, exactly as before frames.
5
+ /** Each frame owns a range of element ids this wide; the main frame is range 0, so its ids are what they always were. */
6
+ export const FRAME_ID_STRIDE = 1_000_000;
7
+ // A frame read that takes longer than this is skipped: a slow third-party frame must not hold up or end the run.
8
+ const FRAME_READ_MS = 1_500;
9
+ // Caps against pages full of ad and tracking frames.
10
+ const MAX_FRAMES = 10;
11
+ const MAX_PLACED = 20;
12
+ const MAX_DEPTH = 3;
13
+ // A frame showing less than this on screen (tracking pixels, collapsed widgets) is not read.
14
+ const MIN_SIDE = 16;
15
+ // The page text keeps its cap; frames get the room the main text leaves, but at least this much when they have text.
16
+ const TEXT_CAP = 6000;
17
+ const FRAME_TEXT_FLOOR = 1000;
18
+ const PASSWORD_FIELDS_CAP = 5;
19
+ const WORLD = 'jevway';
20
+ const OBJECTS = 'jevway-frames';
21
+ /**
22
+ * The first element id of a frame's range.
23
+ *
24
+ * @example
25
+ * idBaseFor(0) // 0: the main frame
26
+ * idBaseFor(2) // 2_000_000
27
+ */
28
+ export const idBaseFor = (slot) => slot * FRAME_ID_STRIDE;
29
+ /**
30
+ * The id range an element id belongs to: 0 for the main frame, so a node routes to the frame that holds it.
31
+ *
32
+ * @example
33
+ * idBaseOf(2_000_017) // 2_000_000
34
+ */
35
+ export const idBaseOf = (node) => node - (node % FRAME_ID_STRIDE);
36
+ /**
37
+ * A frame's offset and visible region in the viewport, from its levels outermost first. Offsets compound through nested
38
+ * frames; each level's visible part is clipped by the one around it. Both are read viewport-relative, so a scrolled page
39
+ * or frame is already counted.
40
+ *
41
+ * @example
42
+ * framePlacement([{ origin: { x: 100, y: 50 }, clip: { x: 100, y: 50, w: 600, h: 400 } }], { w: 1000, h: 800 })
43
+ * // { offset: { x: 100, y: 50 }, region: { x: 100, y: 50, w: 600, h: 400 } }
44
+ */
45
+ export const framePlacement = (levels, viewport) => {
46
+ let offset = { x: 0, y: 0 };
47
+ let region = { x: 0, y: 0, w: viewport.w, h: viewport.h };
48
+ for (const { origin, clip } of levels) {
49
+ region = intersect(region, { ...clip, x: clip.x + offset.x, y: clip.y + offset.y });
50
+ offset = { x: offset.x + origin.x, y: offset.y + origin.y };
51
+ }
52
+ return { offset, region };
53
+ };
54
+ const intersect = (a, b) => {
55
+ const x = Math.max(a.x, b.x);
56
+ const y = Math.max(a.y, b.y);
57
+ return { x, y, w: Math.max(0, Math.min(a.x + a.w, b.x + b.w) - x), h: Math.max(0, Math.min(a.y + a.h, b.y + b.h) - y) };
58
+ };
59
+ /**
60
+ * A frame's element actions in viewport coordinates, each recording its frame. A control the parent hides (clipped, or
61
+ * below the page's fold) is dropped, as off-screen controls of the page are. One the reader listed off-screen inside the
62
+ * frame itself (a wanted row, a sideways column) is kept: clicking scrolls it in. The frame's scroll and wait controls are
63
+ * dropped; the page has its own.
64
+ *
65
+ * @example
66
+ * placeFrameActions(frameState.actions, { w: frameState.w, h: frameState.h }, framePlacement(levels, page), frameId)
67
+ */
68
+ export const placeFrameActions = (actions, frame, { offset, region }, frameId) => actions.flatMap((action) => {
69
+ if (action.node === undefined || !action.rect)
70
+ return [];
71
+ const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
72
+ const local = centerOf(action.rect);
73
+ const insideFrame = local.x >= 0 && local.y >= 0 && local.x < frame.w && local.y < frame.h;
74
+ if (insideFrame && !contains(region, centerOf(rect)))
75
+ return [];
76
+ return [{ ...action, rect, frame: frameId }];
77
+ });
78
+ const passwordLine = (field, first) => `Password field "${field.name}": ${field.filled ? 'filled' : 'empty'}` + (first ? ' (passwords are typed by the user, not by you)' : '');
79
+ /**
80
+ * Joins the frames' snapshots to the main frame's, as one page:
81
+ * - element ids `e1…` continue across frames, the 250 cap counts them all, and the page's scroll and wait stay last;
82
+ * - frame text follows the main text under `[Frame: <label>]`, within the same 6000 characters (frames get at least
83
+ * 1000 when they have text);
84
+ * - password fields of every frame are described first, filled or empty only, at most 5 in all.
85
+ * With no frame text and no frame password field, the main text is returned untouched.
86
+ *
87
+ * @example
88
+ * const merged = mergeFrames(mainState, [paymentFrame]); // merged.actions: e1 "Back to cart" … e4 "Pay"
89
+ */
90
+ export const mergeFrames = (main, frames) => {
91
+ const pageControls = main.actions.filter((action) => action.node === undefined);
92
+ const elements = [...main.actions.filter((action) => action.node !== undefined), ...frames.flatMap((frame) => frame.actions)];
93
+ const omitted_actions = main.omitted_actions + frames.reduce((sum, frame) => sum + frame.omitted_actions, 0) + Math.max(0, elements.length - 250);
94
+ const actions = [...elements.slice(0, 250).map((action, i) => ({ ...action, id: `e${i + 1}` })), ...pageControls];
95
+ // Password lines lead each reader's text; split them off so every frame's go first.
96
+ const mainFields = main.passwordFields ?? [];
97
+ const mainLines = main.text.split('\n');
98
+ const mainBody = mainLines.slice(mainFields.length).join('\n');
99
+ const sections = frames.flatMap((frame) => {
100
+ const body = frame.text.split('\n').slice(frame.passwordFields.length).join('\n');
101
+ return body.trim() ? [`[Frame: ${frame.label}]\n${body}`] : [];
102
+ });
103
+ const passwordFields = [...mainFields, ...frames.flatMap((frame) => frame.passwordFields)].slice(0, PASSWORD_FIELDS_CAP);
104
+ const added = passwordFields.slice(mainFields.length);
105
+ let text = main.text;
106
+ if (sections.length || added.length) {
107
+ const head = [...mainLines.slice(0, mainFields.length), ...added.map((field, i) => passwordLine(field, !mainFields.length && !i))].join('\n');
108
+ const framesText = sections.join('\n');
109
+ const budget = TEXT_CAP - (head ? head.length + 1 : 0);
110
+ const frameRoom = framesText ? Math.min(framesText.length, Math.max(FRAME_TEXT_FLOOR, budget - mainBody.length - 1)) : 0;
111
+ const mainRoom = Math.max(0, budget - (framesText ? frameRoom + 1 : 0));
112
+ text = [head, mainBody.slice(0, mainRoom), framesText.slice(0, frameRoom)].filter(Boolean).join('\n');
113
+ }
114
+ return {
115
+ actions,
116
+ text,
117
+ passwordFields,
118
+ guards: Object.assign({}, main.guards, ...frames.map((frame) => frame.guards)),
119
+ omitted_actions,
120
+ frames: frames.map(({ id, page_key, identity }) => ({ id, page_key, identity })),
121
+ };
122
+ };
123
+ /**
124
+ * The frames of one tab: finds them (same-process frames in the frame tree, out-of-process ones through auto-attach),
125
+ * reads each in its isolated world, places them in the viewport and checks nothing covers them.
126
+ *
127
+ * @example
128
+ * const frames = new Frames(cdp, sessionId, INPUT_COUNTER);
129
+ * await frames.watch();
130
+ * const snapshots = await frames.read({ w: 1120, h: 780 }, READ_STATE, []);
131
+ */
132
+ export class Frames {
133
+ #cdp;
134
+ #sessionId;
135
+ #counter;
136
+ #touch = false;
137
+ #mainFrameId = '';
138
+ #viewport = { w: 0, h: 0 };
139
+ /** Out-of-process frames attached so far, by target id (= frame id). */
140
+ #children = new Map();
141
+ #known = new Map();
142
+ #bases = new Map();
143
+ #contexts = new Map();
144
+ #objectSessions = new Set();
145
+ #unsubscribe;
146
+ constructor(cdp, sessionId, counter) {
147
+ this.#cdp = cdp;
148
+ this.#sessionId = sessionId;
149
+ this.#counter = counter;
150
+ }
151
+ /** Registers the input counter in every frame's world before its scripts, and attaches out-of-process frames as they come. */
152
+ async watch() {
153
+ this.#unsubscribe = this.#cdp.onEvent((method, params, sessionId) => this.#onEvent(method, params, sessionId));
154
+ await this.#send('Page.addScriptToEvaluateOnNewDocument', { source: this.#counter, worldName: WORLD }, this.#sessionId);
155
+ await this.#autoAttach(this.#sessionId);
156
+ }
157
+ /** Stops following the tab's frames (the tab is closing). */
158
+ stop() {
159
+ this.#unsubscribe?.();
160
+ }
161
+ /** Mobile emulation taps with touch events; out-of-process frames get the same setting. */
162
+ set touch(enabled) {
163
+ this.#touch = enabled;
164
+ for (const { sessionId } of this.#children.values())
165
+ void this.#emulateTouch(sessionId);
166
+ }
167
+ /** The frame holding an element id: undefined for the main frame and for a frame no longer on the page. */
168
+ ofNode(node) {
169
+ const base = idBaseOf(node);
170
+ return base ? [...this.#known.values()].find((frame) => frame.base === base) : undefined;
171
+ }
172
+ /** Every frame worth reading, in frame-tree order: loaded (not empty or about:blank), at most 3 levels deep. */
173
+ async list() {
174
+ const { frameTree } = await this.#send('Page.getFrameTree', {}, this.#sessionId);
175
+ this.#mainFrameId = frameTree.frame.id;
176
+ const found = new Map();
177
+ const walk = (node, sessionId) => {
178
+ for (const child of node.childFrames ?? []) {
179
+ found.set(child.frame.id, { id: child.frame.id, parentId: node.frame.id, sessionId, url: child.frame.url });
180
+ walk(child, sessionId);
181
+ }
182
+ };
183
+ walk(frameTree, this.#sessionId);
184
+ const children = [...this.#children].filter(([, child]) => child.parentFrameId);
185
+ const trees = await Promise.all(children.map(([, { sessionId }]) => withTimeout(this.#send('Page.getFrameTree', {}, sessionId), FRAME_READ_MS).catch(() => null)));
186
+ children.forEach(([id, { sessionId, parentFrameId }], i) => {
187
+ const tree = trees[i];
188
+ if (!tree)
189
+ return;
190
+ found.set(id, { id, parentId: parentFrameId, sessionId, url: tree.frameTree.frame.url });
191
+ walk(tree.frameTree, sessionId);
192
+ });
193
+ const depth = (frame, level = 1) => !frame || level > MAX_DEPTH ? Infinity : frame.parentId === this.#mainFrameId ? level : depth(found.get(frame.parentId), level + 1);
194
+ this.#known = new Map();
195
+ for (const frame of found.values()) {
196
+ if (depth(frame) <= MAX_DEPTH)
197
+ this.#known.set(frame.id, { ...frame, base: this.#baseOf(frame.id) });
198
+ }
199
+ return [...this.#known.values()].filter((frame) => frame.url && frame.url !== 'about:blank');
200
+ }
201
+ /**
202
+ * Reads every visible frame with `script` (the snapshot), places its actions in the viewport and drops those the parent
203
+ * covers. A frame that is hidden, too small, too slow or failing is skipped; this never throws for a frame.
204
+ */
205
+ async read(viewport, script, wanted, signal) {
206
+ // A stopped run reads no frame: its page is reported as the main document shows it.
207
+ if (signal?.aborted)
208
+ return [];
209
+ this.#viewport = viewport;
210
+ await this.#releaseObjects();
211
+ const frames = await this.list().catch(() => []);
212
+ // Read side by side, so a page full of slow frames costs one frame's cap, not their sum; a stop ends the slow ones.
213
+ const stop = whenAborted(signal);
214
+ const reads = await Promise.all(frames
215
+ .slice(0, MAX_PLACED)
216
+ .map((frame) => Promise.race([withTimeout(this.#readOne(frame, script, wanted), FRAME_READ_MS + 500).catch(() => null), stop.promise]))).finally(stop.release);
217
+ const read = reads.filter((one) => one !== null).slice(0, MAX_FRAMES);
218
+ // In reading order: top to bottom, then left to right.
219
+ return read.sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x).map(({ snapshot }) => snapshot);
220
+ }
221
+ async #readOne(frame, script, wanted) {
222
+ const levels = await this.#levels(frame);
223
+ if (!levels)
224
+ return null;
225
+ const placement = framePlacement(levels, this.#viewport);
226
+ if (placement.region.w < MIN_SIDE || placement.region.h < MIN_SIDE)
227
+ return null;
228
+ // The text walk keeps to the part of the frame the page shows, in the frame's own coordinates.
229
+ const { offset, region } = placement;
230
+ const view = { x: region.x - offset.x, y: region.y - offset.y, w: region.w, h: region.h };
231
+ const response = await this.evaluate(frame, `window.__jevIdBase=${frame.base};window.__jevView=${JSON.stringify(view)};window.__jevWanted=${JSON.stringify(wanted)};${script}`, { timeout: FRAME_READ_MS });
232
+ const state = response.exceptionDetails ? null : response.result?.value;
233
+ if (!state)
234
+ return null;
235
+ const placed = placeFrameActions(state.actions, { w: state.w, h: state.h }, placement, frame.id);
236
+ // On-screen controls must be on top in every document around the frame: a banner or sheet over the frame hides them.
237
+ const shown = placed.filter((action) => contains(placement.region, centerOf(action.rect)));
238
+ const onTop = await this.#hits(levels, shown.map((action) => centerOf(action.rect)));
239
+ const covered = new Set(shown.filter((_, i) => !onTop[i]));
240
+ const snapshot = {
241
+ id: frame.id,
242
+ label: levels.at(-1).title || String(state.title || '').slice(0, 60) || hostOf(frame.url),
243
+ actions: placed.filter((action) => !covered.has(action)),
244
+ text: state.text,
245
+ passwordFields: state.passwordFields ?? [],
246
+ page_key: state.page_key,
247
+ identity: state.marker?.slice(0, 2),
248
+ guards: state.guards,
249
+ omitted_actions: state.omitted_actions ?? 0,
250
+ };
251
+ return { snapshot, at: placement.offset };
252
+ }
253
+ /** Sum of `script` (the placeholder count) over the listed frames; a frame that fails counts 0. */
254
+ async countPlaceholders(script) {
255
+ const frames = (await this.list().catch(() => [])).slice(0, MAX_PLACED);
256
+ const counts = await Promise.all(frames.map((frame) => withTimeout(this.evaluate(frame, script, { timeout: FRAME_READ_MS }), FRAME_READ_MS + 500).catch(() => null)));
257
+ return counts.reduce((total, response) => total + (Number(response?.result?.value) || 0), 0);
258
+ }
259
+ /** True while every frame read at observe time still shows the same document. */
260
+ async sameDocuments(frames) {
261
+ const same = await Promise.all(frames.map(async ({ id, identity }) => {
262
+ const frame = this.#known.get(id);
263
+ if (!frame)
264
+ return false;
265
+ const response = await withTimeout(this.evaluate(frame, '[performance.timeOrigin, location.href]'), FRAME_READ_MS).catch(() => null);
266
+ return JSON.stringify(response?.result?.value) === JSON.stringify(identity);
267
+ }));
268
+ return same.every(Boolean);
269
+ }
270
+ /** Where the frame's content starts in the viewport now, or null when it no longer shows. */
271
+ async offsetOf(frame) {
272
+ const levels = await this.#levels(frame);
273
+ return levels ? framePlacement(levels, this.#viewport).offset : null;
274
+ }
275
+ /**
276
+ * A point in the frame, in viewport coordinates, checked like a click target in every document around the frame: it
277
+ * must show (not clipped by the parent) and land on the frame there. Otherwise says what covers it, by observed ids.
278
+ */
279
+ async toViewport(frame, point) {
280
+ const levels = await this.#levels(frame);
281
+ if (!levels)
282
+ return { covered: true, coverNodes: [] };
283
+ const { offset, region } = framePlacement(levels, this.#viewport);
284
+ const at = { x: point.x + offset.x, y: point.y + offset.y };
285
+ if (!contains(region, at))
286
+ return { covered: true, coverNodes: [] };
287
+ let parentOffset = { x: 0, y: 0 };
288
+ for (const level of levels) {
289
+ const { result } = await this.#send('Runtime.callFunctionOn', { objectId: level.objectId, functionDeclaration: COVER_AT, arguments: [{ value: at.x - parentOffset.x }, { value: at.y - parentOffset.y }], returnByValue: true }, level.sessionId);
290
+ if (result?.value)
291
+ return { covered: true, coverNodes: result.value.coverNodes ?? [] };
292
+ parentOffset = { x: parentOffset.x + level.origin.x, y: parentOffset.y + level.origin.y };
293
+ }
294
+ return at;
295
+ }
296
+ /**
297
+ * Waits two animation frames in the frame. Chrome routes a press into a frame by what was last painted, so a frame
298
+ * that has just loaded, not yet drawn, would let the press fall through to the page around it.
299
+ */
300
+ async painted(frame) {
301
+ const frames = 'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))';
302
+ await withTimeout(this.evaluate(frame, frames, { awaitPromise: true }), 500).catch(() => undefined);
303
+ }
304
+ /** Runs `expression` in the frame's isolated world; a world lost to a navigation is made again once. */
305
+ async evaluate(frame, expression, extra = {}) {
306
+ const key = `${frame.sessionId}:${frame.id}`;
307
+ for (let attempt = 0;; attempt++) {
308
+ const contextId = await this.#context(frame);
309
+ try {
310
+ return await this.#send('Runtime.evaluate', { expression, contextId, returnByValue: true, ...extra }, frame.sessionId);
311
+ }
312
+ catch (error) {
313
+ if (attempt || !(error instanceof CdpError) || !/context/i.test(error.message))
314
+ throw error;
315
+ this.#contexts.delete(key);
316
+ }
317
+ }
318
+ }
319
+ // Outermost first: for each document from the page down to the frame's parent, the owner <iframe> there.
320
+ async #levels(frame) {
321
+ const levels = [];
322
+ for (let current = frame;;) {
323
+ const atTop = current.parentId === this.#mainFrameId;
324
+ const parent = atTop ? undefined : this.#known.get(current.parentId);
325
+ if (!atTop && !parent)
326
+ return null;
327
+ const sessionId = parent?.sessionId ?? this.#sessionId;
328
+ const { backendNodeId } = await this.#send('DOM.getFrameOwner', { frameId: current.id }, sessionId);
329
+ // The page's own world for the main frame (its observed ids live there), the isolated world for a frame.
330
+ const contextId = parent ? await this.#context(parent) : undefined;
331
+ const { object } = await this.#send('DOM.resolveNode', { backendNodeId, objectGroup: OBJECTS, ...(contextId && { executionContextId: contextId }) }, sessionId);
332
+ this.#objectSessions.add(sessionId);
333
+ const { result } = await this.#send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: OWNER_PLACE, returnByValue: true }, sessionId);
334
+ if (!result?.value)
335
+ return null;
336
+ levels.unshift({ ...result.value, sessionId, objectId: object.objectId });
337
+ if (!parent)
338
+ return levels;
339
+ current = parent;
340
+ }
341
+ }
342
+ // For each viewport point: true when every document around the frame has the frame on top there.
343
+ async #hits(levels, points) {
344
+ const onTop = points.map(() => true);
345
+ if (!points.length)
346
+ return onTop;
347
+ let parentOffset = { x: 0, y: 0 };
348
+ for (const level of levels) {
349
+ const local = points.map(({ x, y }) => [x - parentOffset.x, y - parentOffset.y]);
350
+ const { result } = await this.#send('Runtime.callFunctionOn', { objectId: level.objectId, functionDeclaration: HITS, arguments: [{ value: local }], returnByValue: true }, level.sessionId);
351
+ (result?.value ?? []).forEach((hit, i) => (onTop[i] &&= hit));
352
+ parentOffset = { x: parentOffset.x + level.origin.x, y: parentOffset.y + level.origin.y };
353
+ }
354
+ return onTop;
355
+ }
356
+ async #context(frame) {
357
+ const key = `${frame.sessionId}:${frame.id}`;
358
+ const known = this.#contexts.get(key);
359
+ if (known)
360
+ return known;
361
+ const { executionContextId } = await this.#send('Page.createIsolatedWorld', { frameId: frame.id, worldName: WORLD }, frame.sessionId);
362
+ // A frame that was there before this tab attached never ran the new-document script: register the counter now.
363
+ await this.#send('Runtime.evaluate', { expression: this.#counter, contextId: executionContextId }, frame.sessionId).catch(() => undefined);
364
+ this.#contexts.set(key, executionContextId);
365
+ return executionContextId;
366
+ }
367
+ #baseOf(frameId) {
368
+ if (!this.#bases.has(frameId))
369
+ this.#bases.set(frameId, idBaseFor(this.#bases.size + 1));
370
+ return this.#bases.get(frameId);
371
+ }
372
+ async #releaseObjects() {
373
+ for (const sessionId of this.#objectSessions) {
374
+ await this.#send('Runtime.releaseObjectGroup', { objectGroup: OBJECTS }, sessionId).catch(() => undefined);
375
+ }
376
+ this.#objectSessions.clear();
377
+ }
378
+ #owns(sessionId) {
379
+ return sessionId === this.#sessionId || [...this.#children.values()].some((child) => child.sessionId === sessionId);
380
+ }
381
+ #onEvent(method, params, sessionId) {
382
+ if (!this.#owns(sessionId))
383
+ return;
384
+ if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'iframe')
385
+ void this.#adopt(params);
386
+ if (method === 'Target.detachedFromTarget') {
387
+ for (const [id, child] of this.#children)
388
+ if (child.sessionId === params.sessionId)
389
+ this.#children.delete(id);
390
+ }
391
+ // A new document in a frame has a new world; the old context id is gone with it.
392
+ if (method === 'Page.frameNavigated' || method === 'Page.frameDetached') {
393
+ const frameId = params.frame?.id ?? params.frameId;
394
+ for (const key of this.#contexts.keys())
395
+ if (key.endsWith(`:${frameId}`))
396
+ this.#contexts.delete(key);
397
+ }
398
+ }
399
+ // An out-of-process frame waits for us before running its scripts, so the counter is in place first; it is always resumed.
400
+ async #adopt({ sessionId, targetInfo, waitingForDebugger }) {
401
+ this.#children.set(targetInfo.targetId, { sessionId, parentFrameId: targetInfo.parentFrameId });
402
+ try {
403
+ await this.#send('Page.enable', {}, sessionId);
404
+ await this.#send('Page.addScriptToEvaluateOnNewDocument', { source: this.#counter, worldName: WORLD }, sessionId);
405
+ await this.#send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId).catch(() => undefined);
406
+ await this.#emulateTouch(sessionId);
407
+ await this.#autoAttach(sessionId);
408
+ }
409
+ catch {
410
+ // A frame that went away while being set up; nothing to read.
411
+ }
412
+ finally {
413
+ if (waitingForDebugger)
414
+ await this.#send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => undefined);
415
+ }
416
+ }
417
+ #emulateTouch(sessionId) {
418
+ const enabled = this.#touch;
419
+ return this.#send('Emulation.setTouchEmulationEnabled', { enabled, maxTouchPoints: enabled ? 5 : 1 }, sessionId).catch(() => undefined);
420
+ }
421
+ #autoAttach(sessionId) {
422
+ return this.#send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: 'iframe' }] }, sessionId);
423
+ }
424
+ #send(method, params, sessionId) {
425
+ return this.#cdp.send(method, params, sessionId);
426
+ }
427
+ }
428
+ const hostOf = (url) => {
429
+ try {
430
+ return new URL(url).host || url.slice(0, 60);
431
+ }
432
+ catch {
433
+ return url.slice(0, 60);
434
+ }
435
+ };
436
+ /**
437
+ * A wait's view of the run's stop signal: `promise` resolves null once `signal` aborts (never without one), and
438
+ * `release` drops the listener when the wait is over, so a long call's many waits leave none behind.
439
+ *
440
+ * @example
441
+ * const stop = whenAborted(signal);
442
+ * try { await Promise.race([sleep(50), stop.promise]); } finally { stop.release(); }
443
+ */
444
+ export const whenAborted = (signal) => {
445
+ let release = () => undefined;
446
+ const promise = new Promise((resolve) => {
447
+ if (!signal)
448
+ return;
449
+ if (signal.aborted)
450
+ return resolve(null);
451
+ const listener = () => resolve(null);
452
+ signal.addEventListener('abort', listener, { once: true });
453
+ release = () => signal.removeEventListener('abort', listener);
454
+ });
455
+ return { promise, release: () => release() };
456
+ };
457
+ const withTimeout = (promise, ms) => {
458
+ let timer;
459
+ const timedOut = new Promise((_, reject) => {
460
+ timer = setTimeout(() => reject(new Error(`frame did not answer within ${ms} ms`)), ms);
461
+ });
462
+ return Promise.race([promise, timedOut]).finally(() => clearTimeout(timer));
463
+ };
464
+ // Run on the owner <iframe> in its parent document. Null when the frame is not something a person sees: hidden, inert,
465
+ // aria-hidden (except by the modal library's own marker, as the snapshot reads it), or outside the open modal. Otherwise
466
+ // where its content box starts and the part of it the parent's viewport and scroll boxes leave visible.
467
+ const OWNER_PLACE = `function () {
468
+ const e = this, seen = { checkOpacity: true, checkVisibilityCSS: true };
469
+ if (!e.isConnected || !e.checkVisibility(seen) || e.closest('[inert]')) return null;
470
+ for (let h = e.closest('[aria-hidden="true"]'); h; h = h.parentElement?.closest('[aria-hidden="true"]'))
471
+ if (h.getAttribute('data-aria-hidden') !== 'true') return null;
472
+ const modals = [...document.querySelectorAll('[aria-modal="true"],dialog')].filter(
473
+ (m) => (m.tagName !== 'DIALOG' || m.matches(':modal')) && m.checkVisibility(seen) && m.getBoundingClientRect().width > 0);
474
+ if (modals.length && !modals.some((m) => m.contains(e))) return null;
475
+ const r = e.getBoundingClientRect(), s = getComputedStyle(e), px = (v) => parseFloat(v) || 0;
476
+ const x = r.x + e.clientLeft + px(s.paddingLeft), y = r.y + e.clientTop + px(s.paddingTop);
477
+ let left = Math.max(x, 0), top = Math.max(y, 0),
478
+ right = Math.min(x + e.clientWidth - px(s.paddingLeft) - px(s.paddingRight), innerWidth),
479
+ bottom = Math.min(y + e.clientHeight - px(s.paddingTop) - px(s.paddingBottom), innerHeight);
480
+ for (let p = e.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {
481
+ if (getComputedStyle(p).overflow === 'visible') continue;
482
+ const c = p.getBoundingClientRect();
483
+ left = Math.max(left, c.left); top = Math.max(top, c.top); right = Math.min(right, c.right); bottom = Math.min(bottom, c.bottom);
484
+ }
485
+ return { origin: { x, y }, clip: { x: left, y: top, w: Math.max(0, right - left), h: Math.max(0, bottom - top) },
486
+ title: (e.getAttribute('title') || '').replace(/\\s+/g, ' ').trim().slice(0, 60) };
487
+ }`;
488
+ // The element at a point, through open shadow roots (as tab.ts DEEP_HIT): an <iframe> inside a web component still counts.
489
+ const DEEP_HIT = `(x, y) => {
490
+ let hit = document.elementFromPoint(x, y);
491
+ while (hit?.shadowRoot) {
492
+ const inner = hit.shadowRoot.elementFromPoint(x, y);
493
+ if (!inner || inner === hit) break;
494
+ hit = inner;
495
+ }
496
+ return hit;
497
+ }`;
498
+ // Run on the owner <iframe>: for each point (in this document's viewport), whether the frame is what a press there hits.
499
+ const HITS = `function (points) {
500
+ const deepHit = ${DEEP_HIT};
501
+ return points.map(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && deepHit(x, y) === this);
502
+ }`;
503
+ // Run on the owner <iframe>: null when a press at the point reaches the frame; otherwise the observed ids in the fixed,
504
+ // sticky or modal layer on top there (as the cover check in tab.ts names them).
505
+ const COVER_AT = `function (x, y) {
506
+ const deepHit = ${DEEP_HIT};
507
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return { coverNodes: [] };
508
+ const hit = deepHit(x, y);
509
+ if (hit === this) return null;
510
+ const isLayer = (n) => ['fixed', 'sticky'].includes(getComputedStyle(n).position) ||
511
+ ['dialog', 'alertdialog'].includes(n.getAttribute('role')) || n.getAttribute('aria-modal') === 'true';
512
+ let layer = hit;
513
+ while (layer && layer !== document.body && !isLayer(layer)) layer = layer.parentElement;
514
+ const nodes = window.__jevFast?.nodes;
515
+ return { coverNodes: layer && layer !== document.body && nodes ? [...nodes].filter(([, n]) => layer.contains(n)).map(([id]) => id) : [] };
516
+ }`;