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.
- package/INSTALL-FOR-AGENTS.md +138 -0
- package/LICENSE +11 -0
- package/LICENSE.jev-ultrafast +21 -0
- package/README.md +218 -2
- package/dist/browser/bot-wall.js +105 -0
- package/dist/browser/cdp.js +112 -0
- package/dist/browser/chrome.js +147 -0
- package/dist/browser/devices.js +20 -0
- package/dist/browser/frames.js +516 -0
- package/dist/browser/snapshot.js +611 -0
- package/dist/browser/tab.js +938 -0
- package/dist/browser/words.js +51 -0
- package/dist/cli.js +112 -0
- package/dist/jev/action-space.js +48 -0
- package/dist/jev/box.js +58 -0
- package/dist/jev/decide.js +405 -0
- package/dist/jev/instructions.js +67 -0
- package/dist/jev/model.js +189 -0
- package/dist/jev/replay.js +213 -0
- package/dist/jev/secrets.js +60 -0
- package/dist/journeys/diagnostics.js +85 -0
- package/dist/journeys/journey-doc.js +50 -0
- package/dist/journeys/journey-session.js +503 -0
- package/dist/journeys/pick-next-move.js +59 -0
- package/dist/journeys/render-viewer.js +153 -0
- package/dist/journeys/screen-boxes.js +49 -0
- package/dist/journeys/screen-judgments.js +149 -0
- package/dist/mcp.js +361 -0
- package/dist/setup/doctor-command.js +81 -0
- package/dist/setup/doctor.js +163 -0
- package/dist/setup/env-file.js +50 -0
- package/dist/task/checks.js +156 -0
- package/dist/task/evidence.js +96 -0
- package/dist/task/hosts.js +91 -0
- package/dist/task/look.js +17 -0
- package/dist/task/recording-page.js +310 -0
- package/dist/task/recording.js +232 -0
- package/dist/task/run-limit.js +66 -0
- package/dist/task/run-task.js +1607 -0
- package/dist/task/start-task.js +91 -0
- package/dist/task/upload-files.js +43 -0
- package/package.json +80 -4
- package/skill/jevway/SKILL.md +73 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { Image } from '@typesafe-ai/sdk';
|
|
5
|
+
/**
|
|
6
|
+
* A request the recording never saw: the run can't be answered offline. The message names the request and the changed
|
|
7
|
+
* paths; the page-text lines are in `pageDiff` only, because the run retries a model error whose message reads like a
|
|
8
|
+
* timeout, and a page can say "timed out".
|
|
9
|
+
*/
|
|
10
|
+
export class ReplayMiss extends Error {
|
|
11
|
+
name = 'ReplayMiss';
|
|
12
|
+
pageDiff;
|
|
13
|
+
constructor(message, pageDiff = []) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.pageDiff = pageDiff;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_DIR = 'verify/out/replay/default';
|
|
19
|
+
const IMAGE = '<image>';
|
|
20
|
+
const LOCAL_ORIGIN = /\b(127\.0\.0\.1|localhost):\d+/g;
|
|
21
|
+
/**
|
|
22
|
+
* `JEVWAY_REPLAY`: `record` saves every Jev answer next to its request, `replay` answers only from those recordings;
|
|
23
|
+
* unset means live. Any other value is a configuration error, never a silent live run.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* replayMode(); // 'replay' under JEVWAY_REPLAY=replay
|
|
27
|
+
*/
|
|
28
|
+
export const replayMode = () => {
|
|
29
|
+
const mode = process.env.JEVWAY_REPLAY?.trim();
|
|
30
|
+
if (!mode)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (mode !== 'record' && mode !== 'replay')
|
|
33
|
+
throw new Error(`JEVWAY_REPLAY must be record or replay, got "${mode}".`);
|
|
34
|
+
return mode;
|
|
35
|
+
};
|
|
36
|
+
/** The recording folder: `JEVWAY_REPLAY_DIR`, or `verify/out/replay/default` under the working directory. */
|
|
37
|
+
export const replayDir = () => resolve(process.env.JEVWAY_REPLAY_DIR?.trim() || DEFAULT_DIR);
|
|
38
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
39
|
+
/**
|
|
40
|
+
* The request as it is matched: keys sorted at every depth, every image (an SDK `Image`, an `image_url` part, a
|
|
41
|
+
* `data:image/…` URL) replaced by `<image>`, and every local fixture origin's port by `<port>`. Two runs of the same
|
|
42
|
+
* step on fresh fixture servers then read the same: their only differences are the random port and the screenshot's
|
|
43
|
+
* reference and pixels, and the screenshot is drawn from the same elements the text state lists.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* canonicalRequest({ state: { page: { url: 'http://127.0.0.1:63068/stays.html' } } });
|
|
47
|
+
* // { state: { page: { url: 'http://127.0.0.1:<port>/stays.html' } } }
|
|
48
|
+
*/
|
|
49
|
+
export const canonicalRequest = (value) => {
|
|
50
|
+
if (value instanceof Image || value instanceof Blob)
|
|
51
|
+
return IMAGE;
|
|
52
|
+
if (typeof value === 'string')
|
|
53
|
+
return value.startsWith('data:image/') ? IMAGE : value.replace(LOCAL_ORIGIN, '$1:<port>');
|
|
54
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
55
|
+
return value;
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
return value.map((item) => (item === undefined ? null : canonicalRequest(item)));
|
|
58
|
+
if (isPlainObject(value)) {
|
|
59
|
+
if (value.type === 'image_url')
|
|
60
|
+
return IMAGE;
|
|
61
|
+
const keys = Object.keys(value)
|
|
62
|
+
.filter((key) => value[key] !== undefined)
|
|
63
|
+
.sort();
|
|
64
|
+
return Object.fromEntries(keys.map((key) => [key, canonicalRequest(value[key])]));
|
|
65
|
+
}
|
|
66
|
+
const json = value?.toJSON?.();
|
|
67
|
+
return json === undefined ? null : canonicalRequest(json);
|
|
68
|
+
};
|
|
69
|
+
const keyOf = (model, canonical) => createHash('sha256').update(JSON.stringify({ model, request: canonical })).digest('hex');
|
|
70
|
+
/**
|
|
71
|
+
* The replay key: the sha256 of the model id and the canonical request. The same page asked of another model never
|
|
72
|
+
* matches.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* requestKey('jev-1.14.0', request) === requestKey('jev-1.14.0', sameStepOnAnotherPort); // true
|
|
76
|
+
*/
|
|
77
|
+
export const requestKey = (model, request) => keyOf(model, canonicalRequest(request));
|
|
78
|
+
let recordings = 0;
|
|
79
|
+
/**
|
|
80
|
+
* Wraps the live Ask: each answer is appended to this process's own JSONL file in `dir` (a header line with the route
|
|
81
|
+
* first, then one `{ key, model, request, answer }` line per request), in the order the answers came. A failed request
|
|
82
|
+
* is not recorded. The request is stored as sent, already scrubbed of secret values, with its images left out.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const ask = recordingAsk(liveAsk, { profile: 'jev', model: 'jev-1.14.0', route: 'direct' }, 'verify/out/replay/mine/gotchas');
|
|
86
|
+
*/
|
|
87
|
+
export const recordingAsk = (ask, route, dir = replayDir()) => {
|
|
88
|
+
const file = join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${++recordings}.jsonl`);
|
|
89
|
+
let started = false;
|
|
90
|
+
const write = (line) => appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
91
|
+
return async (request, options) => {
|
|
92
|
+
const answer = await ask(request, options);
|
|
93
|
+
if (!started) {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
write({ header: route });
|
|
96
|
+
started = true;
|
|
97
|
+
}
|
|
98
|
+
const canonical = canonicalRequest(request);
|
|
99
|
+
write({ key: keyOf(route.model, canonical), model: route.model, request: canonical, answer });
|
|
100
|
+
return answer;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const recordingFiles = (dir) => existsSync(dir)
|
|
104
|
+
? readdirSync(dir)
|
|
105
|
+
.filter((file) => file.endsWith('.jsonl'))
|
|
106
|
+
.sort()
|
|
107
|
+
.map((file) => join(dir, file))
|
|
108
|
+
: [];
|
|
109
|
+
const linesOf = (file) => readFileSync(file, 'utf8')
|
|
110
|
+
.split('\n')
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.map((line) => JSON.parse(line));
|
|
113
|
+
const nothingRecorded = (dir) => new Error(`No recorded Jev answers in ${dir}; record them first with JEVWAY_REPLAY=record.`);
|
|
114
|
+
/**
|
|
115
|
+
* The route the recordings in `dir` were made on (the first file's header), so a replay resolves the same profile.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* recordedRoute('verify/out/replay/mine/gotchas'); // { profile: 'jev', model: 'jev-1.14.0', route: 'direct' }
|
|
119
|
+
*/
|
|
120
|
+
export const recordedRoute = (dir = replayDir()) => {
|
|
121
|
+
for (const file of recordingFiles(dir)) {
|
|
122
|
+
const header = linesOf(file).find((line) => 'header' in line);
|
|
123
|
+
if (header)
|
|
124
|
+
return header.header;
|
|
125
|
+
}
|
|
126
|
+
throw nothingRecorded(dir);
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* An Ask that answers only from the recordings in `dir` and never reaches the network: answers come back at once.
|
|
130
|
+
* A request recorded more than once gets its answers in the order they were recorded, then the last one again. A
|
|
131
|
+
* request never recorded throws `ReplayMiss`, naming its goal, step and kind with a diff against the nearest recorded
|
|
132
|
+
* request, and writes one `replay miss:` line to stderr, because a run may end a failed request as a hand-back.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const ask = replayingAsk('jev-1.14.0', 'verify/out/replay/mine/gotchas');
|
|
136
|
+
*/
|
|
137
|
+
export const replayingAsk = (model, dir = replayDir()) => {
|
|
138
|
+
const entries = recordingFiles(dir)
|
|
139
|
+
.flatMap(linesOf)
|
|
140
|
+
.filter((line) => 'key' in line);
|
|
141
|
+
if (!entries.length)
|
|
142
|
+
throw nothingRecorded(dir);
|
|
143
|
+
const answers = new Map();
|
|
144
|
+
for (const entry of entries)
|
|
145
|
+
answers.set(entry.key, [...(answers.get(entry.key) ?? []), entry.answer]);
|
|
146
|
+
const served = new Map();
|
|
147
|
+
const ofModel = entries.filter((entry) => entry.model === model);
|
|
148
|
+
return async (request) => {
|
|
149
|
+
const canonical = canonicalRequest(request);
|
|
150
|
+
const key = keyOf(model, canonical);
|
|
151
|
+
const recorded = answers.get(key);
|
|
152
|
+
if (recorded) {
|
|
153
|
+
const turn = served.get(key) ?? 0;
|
|
154
|
+
served.set(key, turn + 1);
|
|
155
|
+
return structuredClone(recorded[Math.min(turn, recorded.length - 1)]);
|
|
156
|
+
}
|
|
157
|
+
const { headline, brief, paths, pageDiff } = describeMiss(canonical, model, ofModel);
|
|
158
|
+
process.stderr.write(`replay miss: ${headline} · ${brief}\n${[...paths, ...pageDiff].map((line) => ` ${line}\n`).join('')}`);
|
|
159
|
+
throw new ReplayMiss(`Replay miss: ${headline}\n${paths.join('\n')}`, pageDiff);
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
// What a request is about, for a person reading a miss: its goal, the step it was asked at, and its kind.
|
|
163
|
+
const describeRequest = (canonical) => {
|
|
164
|
+
const request = canonical;
|
|
165
|
+
const questions = request.questions ?? {};
|
|
166
|
+
const state = (Array.isArray(request.state) ? request.state.find((part) => isPlainObject(part) && 'page' in part) : request.state);
|
|
167
|
+
const instructions = Object.values(questions).map((question) => question?.instructions);
|
|
168
|
+
const goal = instructions.find((text) => isPlainObject(text) && typeof text.goal === 'string')?.goal ??
|
|
169
|
+
instructions.find((text) => typeof text === 'string' && text.startsWith('Goal: '))?.split('\n')[0].slice('Goal: '.length) ??
|
|
170
|
+
instructions.find((text) => isPlainObject(text) && typeof text.completion_condition === 'string')?.completion_condition;
|
|
171
|
+
const ids = Object.keys(questions);
|
|
172
|
+
const kind = 'operation' in questions ? 'decision' : 'evidence' in questions ? 'evidence' : `follow-up (${ids.slice(0, 4).join(', ')})`;
|
|
173
|
+
const step = Array.isArray(state?.recent_actions) ? state.recent_actions.length + 1 : undefined;
|
|
174
|
+
return { goal: goal, kind, step, pageText: typeof state?.page?.text === 'string' ? state.page.text : undefined };
|
|
175
|
+
};
|
|
176
|
+
// Every leaf of a canonical request by its dotted path.
|
|
177
|
+
const leaves = (value, path = '', into = new Map()) => {
|
|
178
|
+
if (value !== null && typeof value === 'object') {
|
|
179
|
+
for (const [key, inner] of Object.entries(value))
|
|
180
|
+
leaves(inner, path ? `${path}.${key}` : key, into);
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
into.set(path, JSON.stringify(value));
|
|
184
|
+
return into;
|
|
185
|
+
};
|
|
186
|
+
const changedPaths = (a, b) => [...new Set([...a.keys(), ...b.keys()])].filter((path) => a.get(path) !== b.get(path));
|
|
187
|
+
const cut = (text, length) => (text.length > length ? `${text.slice(0, length - 1)}…` : text);
|
|
188
|
+
const MAX_PATHS = 8;
|
|
189
|
+
const MAX_LINES = 6;
|
|
190
|
+
// The nearest recorded request with the same goal (any, when none shares it), and what differs from it.
|
|
191
|
+
const describeMiss = (canonical, model, recorded) => {
|
|
192
|
+
const asked = describeRequest(canonical);
|
|
193
|
+
const headline = [`"${cut(asked.goal ?? 'no goal', 80)}"`, ...(asked.step ? [`step ${asked.step}`] : []), asked.kind].join(' · ');
|
|
194
|
+
if (!recorded.length)
|
|
195
|
+
return { headline, brief: `nothing was recorded for ${model}`, paths: [`nothing was recorded for ${model}`], pageDiff: [] };
|
|
196
|
+
const sameGoal = recorded.filter((entry) => describeRequest(entry.request).goal === asked.goal);
|
|
197
|
+
const askedLeaves = leaves(canonical);
|
|
198
|
+
const [nearest] = (sameGoal.length ? sameGoal : recorded)
|
|
199
|
+
.map((entry) => ({ entry, changed: changedPaths(leaves(entry.request), askedLeaves) }))
|
|
200
|
+
.sort((a, b) => a.changed.length - b.changed.length);
|
|
201
|
+
const paths = [
|
|
202
|
+
`nearest recorded request${sameGoal.length ? ' with the same goal' : ''} differs at ${nearest.changed.length} paths:`,
|
|
203
|
+
...nearest.changed.slice(0, MAX_PATHS).map((path) => ` ${path}`),
|
|
204
|
+
];
|
|
205
|
+
const pageDiff = [];
|
|
206
|
+
const before = describeRequest(nearest.entry.request).pageText;
|
|
207
|
+
if (before !== undefined && asked.pageText !== undefined && before !== asked.pageText) {
|
|
208
|
+
const [was, now] = [before.split('\n'), asked.pageText.split('\n')];
|
|
209
|
+
const onlyIn = (lines, other) => lines.filter((line) => !other.includes(line)).slice(0, MAX_LINES).map((line) => ` ${cut(line, 120)}`);
|
|
210
|
+
pageDiff.push('page text only recorded:', ...onlyIn(was, now), 'page text only now:', ...onlyIn(now, was));
|
|
211
|
+
}
|
|
212
|
+
return { headline, brief: `differs at ${nearest.changed.slice(0, 3).join(', ')}`, paths, pageDiff };
|
|
213
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** What stands in for a secret value wherever it would otherwise show: Jev's request, the caller's summary, traces, recordings. */
|
|
2
|
+
export const secretMarker = (key) => `[secret:${key}]`;
|
|
3
|
+
const secretEntries = (values, secretKeys) => Object.entries(values).filter(([key, value]) => secretKeys.includes(key) && value.trim() !== '');
|
|
4
|
+
/**
|
|
5
|
+
* The supplied values as Jev may see them: each key the caller marked secret carries its marker, so Jev can still pick
|
|
6
|
+
* the field it belongs in by its name, and never reads the value. With nothing marked, the same object comes back.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* shownValues({ token: 'ghp_x1', repo: 'ada/app' }, ['token']) // { token: '[secret:token]', repo: 'ada/app' }
|
|
10
|
+
*/
|
|
11
|
+
export const shownValues = (values, secretKeys = []) => {
|
|
12
|
+
const secrets = secretEntries(values, secretKeys);
|
|
13
|
+
if (!secrets.length)
|
|
14
|
+
return values;
|
|
15
|
+
return { ...values, ...Object.fromEntries(secrets.map(([key]) => [key, secretMarker(key)])) };
|
|
16
|
+
};
|
|
17
|
+
// How a page may echo a value back: as typed, trimmed, or URL- and form-encoded in an address.
|
|
18
|
+
const formsOf = (value) => {
|
|
19
|
+
const encoded = encodeURIComponent(value.trim());
|
|
20
|
+
return [value, value.trim(), encodeURIComponent(value), encoded, encoded.replace(/%20/g, '+')];
|
|
21
|
+
};
|
|
22
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
24
|
+
// An image part of a request is pixels: its base64 can contain a short secret by chance, and a replacement would corrupt it.
|
|
25
|
+
const isImagePart = (value) => value.type === 'image_url';
|
|
26
|
+
/**
|
|
27
|
+
* A function that replaces every secret value in a string, or in every string of a plain object or array (keys,
|
|
28
|
+
* numbers, class instances such as an image, and `image_url` parts are left alone), with its key's marker. The business rule: a value the
|
|
29
|
+
* caller marked secret never reaches the model or any output, including when the page echoes it (upper-cased, in an
|
|
30
|
+
* address). Matching is case-insensitive and takes the longest form first, in one pass, so a marker is never scrubbed
|
|
31
|
+
* again. Every non-empty value counts, even a short one: a garbled text is the safe failure, a leaked secret is not.
|
|
32
|
+
* With nothing marked it is the identity.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const scrub = scrubberFor({ token: 'ghp_x1' }, ['token']);
|
|
36
|
+
* scrub({ page_text: 'Token GHP_X1 created' }) // { page_text: 'Token [secret:token] created' }
|
|
37
|
+
*/
|
|
38
|
+
export const scrubberFor = (values, secretKeys = []) => {
|
|
39
|
+
const keyByForm = new Map();
|
|
40
|
+
for (const [key, value] of secretEntries(values, secretKeys)) {
|
|
41
|
+
for (const form of formsOf(value))
|
|
42
|
+
if (form && !keyByForm.has(form.toLowerCase()))
|
|
43
|
+
keyByForm.set(form.toLowerCase(), key);
|
|
44
|
+
}
|
|
45
|
+
if (!keyByForm.size)
|
|
46
|
+
return (value) => value;
|
|
47
|
+
const forms = [...keyByForm.keys()].sort((a, b) => b.length - a.length);
|
|
48
|
+
const pattern = new RegExp(forms.map(escapeRegExp).join('|'), 'gi');
|
|
49
|
+
const scrubText = (text) => text.replace(pattern, (match) => secretMarker(keyByForm.get(match.toLowerCase())));
|
|
50
|
+
const scrub = (value) => {
|
|
51
|
+
if (typeof value === 'string')
|
|
52
|
+
return scrubText(value);
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return value.map(scrub);
|
|
55
|
+
if (isPlainObject(value) && !isImagePart(value))
|
|
56
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, scrub(inner)]));
|
|
57
|
+
return value;
|
|
58
|
+
};
|
|
59
|
+
return (value) => scrub(value);
|
|
60
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const MAX_ENTRIES = 20;
|
|
2
|
+
const clip = (text, length = 300) => (text.length > length ? `${text.slice(0, length)}…` : text);
|
|
3
|
+
/**
|
|
4
|
+
* Collects console errors, uncaught exceptions and failed requests for a tab, and tracks in-flight
|
|
5
|
+
* requests so a screenshot waits for the screen to finish loading.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const diagnostics = await watchDiagnostics(tab);
|
|
9
|
+
* await tab.act(action, page);
|
|
10
|
+
* await diagnostics.waitForIdle();
|
|
11
|
+
* const { consoleErrors, failedRequests } = diagnostics.drain(); // errors since the last drain
|
|
12
|
+
*/
|
|
13
|
+
export const watchDiagnostics = async (tab) => {
|
|
14
|
+
let current = { consoleErrors: [], failedRequests: [] };
|
|
15
|
+
const urls = new Map();
|
|
16
|
+
const inflight = new Set();
|
|
17
|
+
let lastActivity = Date.now();
|
|
18
|
+
const addError = (text) => {
|
|
19
|
+
if (current.consoleErrors.length < MAX_ENTRIES)
|
|
20
|
+
current.consoleErrors.push(clip(text));
|
|
21
|
+
};
|
|
22
|
+
const addFailure = (failure) => {
|
|
23
|
+
// Ad blockers and cancelled navigations are the viewer's environment, not the site's bugs.
|
|
24
|
+
const environmental = failure.error === 'net::ERR_BLOCKED_BY_CLIENT' || failure.error === 'net::ERR_ABORTED';
|
|
25
|
+
if (environmental || failure.url.startsWith('data:') || current.failedRequests.length >= MAX_ENTRIES)
|
|
26
|
+
return;
|
|
27
|
+
if (current.failedRequests.some((existing) => existing.url === clip(failure.url, 200)))
|
|
28
|
+
return;
|
|
29
|
+
current.failedRequests.push({ ...failure, url: clip(failure.url, 200) });
|
|
30
|
+
};
|
|
31
|
+
const unsubscribe = tab.onEvent((method, params) => {
|
|
32
|
+
switch (method) {
|
|
33
|
+
case 'Runtime.consoleAPICalled':
|
|
34
|
+
if (params.type === 'error')
|
|
35
|
+
addError(params.args.map((arg) => arg.value ?? arg.description).join(' '));
|
|
36
|
+
break;
|
|
37
|
+
case 'Runtime.exceptionThrown':
|
|
38
|
+
addError(params.exceptionDetails.exception?.description ?? params.exceptionDetails.text);
|
|
39
|
+
break;
|
|
40
|
+
case 'Network.requestWillBeSent':
|
|
41
|
+
urls.set(params.requestId, params.request.url);
|
|
42
|
+
inflight.add(params.requestId);
|
|
43
|
+
lastActivity = Date.now();
|
|
44
|
+
break;
|
|
45
|
+
case 'Network.responseReceived':
|
|
46
|
+
if (params.response.status >= 400)
|
|
47
|
+
addFailure({ url: params.response.url, status: params.response.status });
|
|
48
|
+
break;
|
|
49
|
+
case 'Network.loadingFinished':
|
|
50
|
+
case 'Network.loadingFailed':
|
|
51
|
+
inflight.delete(params.requestId);
|
|
52
|
+
lastActivity = Date.now();
|
|
53
|
+
if (method === 'Network.loadingFailed' && !params.canceled) {
|
|
54
|
+
addFailure({ url: urls.get(params.requestId) ?? '', error: params.errorText });
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
await Promise.all([tab.send('Runtime.enable'), tab.send('Network.enable')]);
|
|
60
|
+
return {
|
|
61
|
+
/** Returns everything collected since the previous drain. */
|
|
62
|
+
drain() {
|
|
63
|
+
const collected = current;
|
|
64
|
+
current = { consoleErrors: [], failedRequests: [] };
|
|
65
|
+
return collected;
|
|
66
|
+
},
|
|
67
|
+
/** Waits until the network is quiet, then for visible images to decode. Never waits longer than maxMs. */
|
|
68
|
+
async waitForIdle({ quietMs = 400, maxMs = 4000 } = {}) {
|
|
69
|
+
const deadline = Date.now() + maxMs;
|
|
70
|
+
// Long-polling and analytics beacons can keep a request open forever; quiet time still counts past 2 in flight.
|
|
71
|
+
while (Date.now() < deadline && (inflight.size > 2 || Date.now() - lastActivity < quietMs)) {
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
73
|
+
}
|
|
74
|
+
await tab
|
|
75
|
+
.evaluate(`Promise.race([
|
|
76
|
+
Promise.all([...document.images]
|
|
77
|
+
.filter(i => !i.complete && i.getBoundingClientRect().top < innerHeight)
|
|
78
|
+
.map(i => new Promise(r => { i.onload = i.onerror = r; }))),
|
|
79
|
+
new Promise(r => setTimeout(r, ${Math.max(0, Math.min(1500, deadline - Date.now()))}))
|
|
80
|
+
]).then(() => true)`)
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
},
|
|
83
|
+
stop: unsubscribe,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const hotspotOf = (action, page) => {
|
|
2
|
+
if (!action.rect || !page.w || !page.h)
|
|
3
|
+
return null;
|
|
4
|
+
const clamp = (value) => Math.min(1, Math.max(0, value));
|
|
5
|
+
return {
|
|
6
|
+
x: clamp(action.rect.x / page.w),
|
|
7
|
+
y: clamp(action.rect.y / page.h),
|
|
8
|
+
width: clamp(action.rect.w / page.w),
|
|
9
|
+
height: clamp(action.rect.h / page.h),
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Groups steps into named flows in first-seen order. A flow name like "Homes/Searching homes" nests under
|
|
14
|
+
* "Homes". Consecutive steps chain screens; a gap (the flow resumes from another screen) starts a new segment
|
|
15
|
+
* inside the same flow.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* buildFlows([{ from: 's01', to: 's02', flow: 'Search', action: { operation: 'CLICK', label: 'Search' }, … }])
|
|
19
|
+
* // [{ name: 'Search', parent: null, screens: [{ screen: 's01', action: 'Tap "Search"', hotspot }, { screen: 's02' }] }]
|
|
20
|
+
*/
|
|
21
|
+
export const buildFlows = (steps) => {
|
|
22
|
+
const flows = new Map();
|
|
23
|
+
for (const step of steps) {
|
|
24
|
+
const flow = flows.get(step.flow) ?? { name: step.flow, parent: parentOf(step.flow), screens: [] };
|
|
25
|
+
flows.set(step.flow, flow);
|
|
26
|
+
if (flow.screens.at(-1)?.screen !== step.from)
|
|
27
|
+
flow.screens.push({ screen: step.from });
|
|
28
|
+
const current = flow.screens.at(-1);
|
|
29
|
+
// Several interactions on one screen (type, pick a suggestion, then submit) read as one card.
|
|
30
|
+
current.action = current.action ? `${current.action}, then ${describeAction(step)}` : describeAction(step);
|
|
31
|
+
if (step.hotspot)
|
|
32
|
+
current.hotspot = step.hotspot;
|
|
33
|
+
if (step.to !== step.from)
|
|
34
|
+
flow.screens.push({ screen: step.to });
|
|
35
|
+
}
|
|
36
|
+
// Parents that only exist as a prefix still appear in the tree.
|
|
37
|
+
for (const flow of [...flows.values()]) {
|
|
38
|
+
let parent = flow.parent;
|
|
39
|
+
while (parent && !flows.has(parent)) {
|
|
40
|
+
flows.set(parent, { name: parent, parent: parentOf(parent), screens: [] });
|
|
41
|
+
parent = parentOf(parent);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...flows.values()];
|
|
45
|
+
};
|
|
46
|
+
const parentOf = (name) => (name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : null);
|
|
47
|
+
const describeAction = (step) => {
|
|
48
|
+
const verb = { CLICK: 'Tap', TYPE_TEXT: 'Type into', SELECT: 'Choose' }[step.action.operation] ?? step.action.operation;
|
|
49
|
+
return `${verb} "${step.action.label}"${step.action.value ? ` → "${step.action.value}"` : ''}`;
|
|
50
|
+
};
|