jevway 0.1.0 → 0.2.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/INSTALL-FOR-AGENTS.md +32 -38
- package/README.md +193 -150
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +74 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +47 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +173 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +320 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/instructions.js +18 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +50 -0
- package/dist/mcp/task-tools.js +197 -0
- package/dist/mcp.js +12 -337
- package/dist/output/layout.js +50 -0
- package/dist/output/manifest.js +80 -0
- package/dist/output/run-folder.js +38 -0
- package/dist/recording/describe-step.js +66 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +193 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +101 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/present.js +90 -0
- package/dist/task/run-task.js +74 -11
- package/dist/task/start-task.js +21 -5
- package/package.json +12 -7
- package/skill/jevway/SKILL.md +50 -14
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createConnection, createServer } from 'node:net';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { ensureOutRoot } from '../output/layout.js';
|
|
5
|
+
import { createSessionService, UnknownSession } from '../session/session-service.js';
|
|
6
|
+
import { presentSummary } from '../task/present.js';
|
|
7
|
+
import { readLines, sendLine } from './protocol.js';
|
|
8
|
+
/** Another daemon already answers on the socket. */
|
|
9
|
+
export class DaemonRunning extends Error {
|
|
10
|
+
code = 'daemon_running';
|
|
11
|
+
}
|
|
12
|
+
const STOP_WAIT_MS = 10_000;
|
|
13
|
+
// 3600000 → '1 h', 1800000 → '30 min'.
|
|
14
|
+
const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
|
|
15
|
+
const listen = (server, path) => new Promise((resolve, reject) => {
|
|
16
|
+
server.once('error', reject);
|
|
17
|
+
server.listen(path, () => {
|
|
18
|
+
server.off('error', reject);
|
|
19
|
+
resolve();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
const answers = (path) => new Promise((resolve) => {
|
|
23
|
+
const probe = createConnection(path);
|
|
24
|
+
probe.once('connect', () => {
|
|
25
|
+
probe.destroy();
|
|
26
|
+
resolve(true);
|
|
27
|
+
});
|
|
28
|
+
probe.once('error', () => resolve(false));
|
|
29
|
+
});
|
|
30
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
31
|
+
const errorBody = (error) => {
|
|
32
|
+
if (error instanceof UnknownSession) {
|
|
33
|
+
return { code: 'unknown_session', message: error.message, hint: 'List the open ones with jevway sessions, or start one with jevway run --session new --url <url> --goal <goal>' };
|
|
34
|
+
}
|
|
35
|
+
return { code: 'failed', message: error instanceof Error ? error.message : String(error), hint: 'The message says what failed; jevway doctor checks the key and the browser.' };
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Serves the session service over a local socket until stopped or idle: one runner per browser/model choice, one
|
|
39
|
+
* service per output root, sessions found by id in whichever service holds them, and `sessions.json` kept in each
|
|
40
|
+
* output root. A socket file nothing answers on is replaced; one another daemon answers on is `DaemonRunning`.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const daemon = await startDaemon({ socket, version, fingerprint, idleMs: 3_600_000, runnerFor, log });
|
|
44
|
+
* await daemon.closed;
|
|
45
|
+
*/
|
|
46
|
+
export const startDaemon = async ({ socket, version, fingerprint, idleMs, runnerFor, log }) => {
|
|
47
|
+
const runners = new Map();
|
|
48
|
+
const services = new Map();
|
|
49
|
+
const connections = new Set();
|
|
50
|
+
const controllers = new Set();
|
|
51
|
+
const pending = new Set();
|
|
52
|
+
const busy = new Map();
|
|
53
|
+
const startedAt = new Date();
|
|
54
|
+
let lastActivity = Date.now();
|
|
55
|
+
let stopping;
|
|
56
|
+
let markClosed;
|
|
57
|
+
const closed = new Promise((resolve) => (markClosed = resolve));
|
|
58
|
+
const lifetime = `Sessions end when the daemon stops${idleMs ? ` or after ${idleWords(idleMs)} idle` : ''}`;
|
|
59
|
+
const unknown = (id, kind) => new UnknownSession(kind === 'session'
|
|
60
|
+
? `Unknown session "${id}". ${lifetime}; start a new one with --session new and --url (or --tab).`
|
|
61
|
+
: `Unknown journey "${id}". Journeys end when finished, or as sessions do: ${lifetime.toLowerCase()}; start a new one with jevway journey start --url <url>.`);
|
|
62
|
+
const allSessions = () => [...services.values()].flatMap(({ service }) => service.listSessions());
|
|
63
|
+
const holderOf = (id, kind) => {
|
|
64
|
+
for (const holder of services.values())
|
|
65
|
+
if (holder.service.listSessions().some(({ session }) => session === id))
|
|
66
|
+
return holder;
|
|
67
|
+
throw unknown(id, kind);
|
|
68
|
+
};
|
|
69
|
+
const serviceFor = (choice, place) => {
|
|
70
|
+
const runnerKey = JSON.stringify([choice.browser, choice.model ?? null, choice.screenshots ?? null]);
|
|
71
|
+
let runner = runners.get(runnerKey);
|
|
72
|
+
if (!runner)
|
|
73
|
+
runners.set(runnerKey, (runner = runnerFor(choice)));
|
|
74
|
+
const key = JSON.stringify([runnerKey, place.outDir, place.journeysDir ?? null]);
|
|
75
|
+
let holder = services.get(key);
|
|
76
|
+
if (!holder) {
|
|
77
|
+
const env = place.journeysDir ? { JEVWAY_JOURNEYS_DIR: place.journeysDir } : {};
|
|
78
|
+
holder = { service: createSessionService(runner, { outDir: place.outDir, cwd: place.cwd, env, idleMs: idleMs || undefined }), outDir: place.outDir };
|
|
79
|
+
services.set(key, holder);
|
|
80
|
+
}
|
|
81
|
+
return holder;
|
|
82
|
+
};
|
|
83
|
+
/** Rewrites `<root>/sessions.json` in every output root this daemon writes to. */
|
|
84
|
+
const writeSessionFiles = () => {
|
|
85
|
+
const byRoot = new Map();
|
|
86
|
+
for (const { service, outDir } of services.values())
|
|
87
|
+
byRoot.set(outDir, [...(byRoot.get(outDir) ?? []), ...service.listSessions()]);
|
|
88
|
+
for (const [root, sessions] of byRoot) {
|
|
89
|
+
try {
|
|
90
|
+
ensureOutRoot(root);
|
|
91
|
+
sessions.sort((a, b) => a.started_at.localeCompare(b.started_at));
|
|
92
|
+
writeFileSync(join(root, 'sessions.json'), `${JSON.stringify({ updated_at: new Date().toISOString(), sessions }, null, 1)}\n`);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A folder that can't be written never fails a call.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const endOne = ({ service }, { session, kind }, keepTab = false) => kind === 'journey' ? service.journeyFinish(session, { keepTab }) : service.end(session, { keepTab });
|
|
100
|
+
const hello = () => ({
|
|
101
|
+
version,
|
|
102
|
+
fingerprint,
|
|
103
|
+
pid: process.pid,
|
|
104
|
+
sessions: allSessions().length,
|
|
105
|
+
idle_timeout_s: idleMs ? idleMs / 1000 : null,
|
|
106
|
+
started_at: startedAt.toISOString(),
|
|
107
|
+
});
|
|
108
|
+
const withoutPreview = ({ journey, report }) => ({ journey, ...report });
|
|
109
|
+
const handlers = {
|
|
110
|
+
hello: () => hello(),
|
|
111
|
+
async task({ request, detail, runner, place }, { signal, emit, track }) {
|
|
112
|
+
const { service } = request.session ? holderOf(request.session, 'session') : serviceFor(runner, place);
|
|
113
|
+
const result = await service.task(request, {
|
|
114
|
+
signal,
|
|
115
|
+
onEvent: (event) => {
|
|
116
|
+
if (event.type === 'started') {
|
|
117
|
+
track(event.session);
|
|
118
|
+
writeSessionFiles();
|
|
119
|
+
}
|
|
120
|
+
emit(event);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
writeSessionFiles();
|
|
124
|
+
return presentSummary(result, { detail });
|
|
125
|
+
},
|
|
126
|
+
async look({ session }) {
|
|
127
|
+
return { session, ...(await holderOf(session, 'session').service.look({ session })) };
|
|
128
|
+
},
|
|
129
|
+
async end({ session, keepTab }) {
|
|
130
|
+
const holder = holderOf(session, 'session');
|
|
131
|
+
const info = holder.service.listSessions().find((entry) => entry.session === session);
|
|
132
|
+
const ended = await endOne(holder, info, keepTab);
|
|
133
|
+
writeSessionFiles();
|
|
134
|
+
return info.kind === 'journey' ? { journey: session, ...ended } : ended;
|
|
135
|
+
},
|
|
136
|
+
sessions: () => ({ daemon: hello(), sessions: allSessions().sort((a, b) => a.started_at.localeCompare(b.started_at)) }),
|
|
137
|
+
async 'journey.start'({ request, runner, place }) {
|
|
138
|
+
const reply = await serviceFor(runner, place).service.journeyStart(request);
|
|
139
|
+
writeSessionFiles();
|
|
140
|
+
return withoutPreview(reply);
|
|
141
|
+
},
|
|
142
|
+
async 'journey.step'({ journey, input }) {
|
|
143
|
+
return withoutPreview(await holderOf(journey, 'journey').service.journeyStep(journey, input));
|
|
144
|
+
},
|
|
145
|
+
async 'journey.autopilot'({ journey, input }) {
|
|
146
|
+
return withoutPreview(await holderOf(journey, 'journey').service.journeyAutopilot(journey, input));
|
|
147
|
+
},
|
|
148
|
+
'journey.annotate'({ journey, screen, notes, relabel }) {
|
|
149
|
+
holderOf(journey, 'journey').service.journeyAnnotate(journey, screen, notes, relabel);
|
|
150
|
+
return { journey, screen, annotated: true };
|
|
151
|
+
},
|
|
152
|
+
async 'journey.finish'({ journey, keepTab }) {
|
|
153
|
+
const summary = await holderOf(journey, 'journey').service.journeyFinish(journey, { keepTab });
|
|
154
|
+
writeSessionFiles();
|
|
155
|
+
return { journey, ...summary };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
// Ending (not destroying) lets the last replies reach their clients first.
|
|
159
|
+
const finish = () => {
|
|
160
|
+
for (const connection of connections) {
|
|
161
|
+
connection.end();
|
|
162
|
+
setTimeout(() => connection.destroy(), 1_000).unref();
|
|
163
|
+
}
|
|
164
|
+
markClosed();
|
|
165
|
+
};
|
|
166
|
+
const shutdown = (reason) => (stopping ??= (async () => {
|
|
167
|
+
clearInterval(timer);
|
|
168
|
+
server.close();
|
|
169
|
+
for (const controller of controllers)
|
|
170
|
+
controller.abort();
|
|
171
|
+
await Promise.race([Promise.allSettled([...pending]), delay(STOP_WAIT_MS)]);
|
|
172
|
+
let ended = 0;
|
|
173
|
+
for (const holder of services.values()) {
|
|
174
|
+
for (const info of holder.service.listSessions()) {
|
|
175
|
+
try {
|
|
176
|
+
await endOne(holder, info);
|
|
177
|
+
ended++;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// A tab that is already gone is ended all the same.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const runner of runners.values())
|
|
185
|
+
await runner.close().catch(() => undefined);
|
|
186
|
+
writeSessionFiles();
|
|
187
|
+
log({ event: 'stopped', reason, ended });
|
|
188
|
+
return ended;
|
|
189
|
+
})());
|
|
190
|
+
const handle = async (socket, mine, { id, method, params = {} }) => {
|
|
191
|
+
if (method === 'stop') {
|
|
192
|
+
const ended = await shutdown('stop');
|
|
193
|
+
sendLine(socket, { id, result: { stopped: true, ended } });
|
|
194
|
+
finish();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (stopping) {
|
|
198
|
+
sendLine(socket, { id, error: { code: 'daemon_unavailable', message: 'The daemon is stopping.', hint: 'Run the command again: it starts a new daemon.' } });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const handler = handlers[method];
|
|
202
|
+
if (!handler) {
|
|
203
|
+
sendLine(socket, { id, error: { code: 'unknown_method', message: `Unknown method "${method}"`, hint: 'The daemon and this jevway may differ in version: run jevway stop.' } });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
mine.set(id, controller);
|
|
208
|
+
controllers.add(controller);
|
|
209
|
+
const tracked = [];
|
|
210
|
+
const track = (session) => {
|
|
211
|
+
tracked.push(session);
|
|
212
|
+
busy.set(session, (busy.get(session) ?? 0) + 1);
|
|
213
|
+
};
|
|
214
|
+
for (const session of [params.session, params.journey, params.request?.session])
|
|
215
|
+
if (typeof session === 'string')
|
|
216
|
+
track(session);
|
|
217
|
+
const began = Date.now();
|
|
218
|
+
lastActivity = began;
|
|
219
|
+
let failed;
|
|
220
|
+
const work = (async () => {
|
|
221
|
+
try {
|
|
222
|
+
const result = await handler(params, { signal: controller.signal, emit: (event) => sendLine(socket, { id, event }), track });
|
|
223
|
+
sendLine(socket, { id, result });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const body = errorBody(error);
|
|
227
|
+
failed = body.code;
|
|
228
|
+
sendLine(socket, { id, error: body });
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
pending.add(work);
|
|
232
|
+
await work;
|
|
233
|
+
pending.delete(work);
|
|
234
|
+
mine.delete(id);
|
|
235
|
+
controllers.delete(controller);
|
|
236
|
+
for (const session of tracked) {
|
|
237
|
+
const left = (busy.get(session) ?? 1) - 1;
|
|
238
|
+
if (left > 0)
|
|
239
|
+
busy.set(session, left);
|
|
240
|
+
else
|
|
241
|
+
busy.delete(session);
|
|
242
|
+
}
|
|
243
|
+
lastActivity = Date.now();
|
|
244
|
+
log({ event: 'request', method, id, ...(tracked[0] && { session: tracked[0] }), ms: lastActivity - began, ...(failed && { error: failed }) });
|
|
245
|
+
};
|
|
246
|
+
const onConnection = (socket) => {
|
|
247
|
+
connections.add(socket);
|
|
248
|
+
const mine = new Map();
|
|
249
|
+
readLines(socket, (message) => {
|
|
250
|
+
if (typeof message?.cancel === 'number')
|
|
251
|
+
mine.get(message.cancel)?.abort();
|
|
252
|
+
else if (typeof message?.id === 'number' && typeof message?.method === 'string')
|
|
253
|
+
void handle(socket, mine, message);
|
|
254
|
+
});
|
|
255
|
+
// A client that goes away cancels its running calls, as a cancelled MCP request does.
|
|
256
|
+
socket.on('close', () => {
|
|
257
|
+
connections.delete(socket);
|
|
258
|
+
for (const controller of mine.values())
|
|
259
|
+
controller.abort();
|
|
260
|
+
});
|
|
261
|
+
socket.on('error', () => undefined);
|
|
262
|
+
};
|
|
263
|
+
let sweeping = false;
|
|
264
|
+
const sweep = async () => {
|
|
265
|
+
if (sweeping || stopping)
|
|
266
|
+
return;
|
|
267
|
+
sweeping = true;
|
|
268
|
+
try {
|
|
269
|
+
const cutoff = Date.now() - idleMs;
|
|
270
|
+
let changed = false;
|
|
271
|
+
for (const holder of services.values()) {
|
|
272
|
+
for (const info of holder.service.listSessions()) {
|
|
273
|
+
if (info.running || busy.has(info.session) || Date.parse(info.last_used_at) > cutoff)
|
|
274
|
+
continue;
|
|
275
|
+
await endOne(holder, info).catch(() => undefined);
|
|
276
|
+
changed = true;
|
|
277
|
+
log({ event: 'idle_end', session: info.session, kind: info.kind });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (changed)
|
|
281
|
+
writeSessionFiles();
|
|
282
|
+
if (!allSessions().length && !pending.size && lastActivity <= cutoff) {
|
|
283
|
+
await shutdown('idle');
|
|
284
|
+
finish();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
sweeping = false;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
if (process.platform !== 'win32')
|
|
292
|
+
mkdirSync(dirname(socket), { recursive: true, mode: 0o700 });
|
|
293
|
+
let server = createServer(onConnection);
|
|
294
|
+
try {
|
|
295
|
+
await listen(server, socket);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error.code !== 'EADDRINUSE')
|
|
299
|
+
throw error;
|
|
300
|
+
if (await answers(socket))
|
|
301
|
+
throw new DaemonRunning(`A jevway daemon already answers on ${socket}`);
|
|
302
|
+
// A socket file left by a daemon that died: nothing answers on it.
|
|
303
|
+
rmSync(socket, { force: true });
|
|
304
|
+
server = createServer(onConnection);
|
|
305
|
+
await listen(server, socket);
|
|
306
|
+
}
|
|
307
|
+
if (process.platform !== 'win32')
|
|
308
|
+
chmodSync(socket, 0o600);
|
|
309
|
+
const timer = idleMs > 0 ? setInterval(() => void sweep(), Math.min(Math.max(idleMs / 4, 25), 60_000)) : undefined;
|
|
310
|
+
timer?.unref();
|
|
311
|
+
log({ event: 'started', pid: process.pid, version, socket, idle_timeout_s: idleMs ? idleMs / 1000 : null });
|
|
312
|
+
return {
|
|
313
|
+
socket,
|
|
314
|
+
closed,
|
|
315
|
+
async stop() {
|
|
316
|
+
await shutdown('stopped');
|
|
317
|
+
finish();
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
};
|
package/dist/jev/replay.js
CHANGED
|
@@ -15,7 +15,6 @@ export class ReplayMiss extends Error {
|
|
|
15
15
|
this.pageDiff = pageDiff;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
const DEFAULT_DIR = 'verify/out/replay/default';
|
|
19
18
|
const IMAGE = '<image>';
|
|
20
19
|
const LOCAL_ORIGIN = /\b(127\.0\.0\.1|localhost):\d+/g;
|
|
21
20
|
/**
|
|
@@ -33,8 +32,13 @@ export const replayMode = () => {
|
|
|
33
32
|
throw new Error(`JEVWAY_REPLAY must be record or replay, got "${mode}".`);
|
|
34
33
|
return mode;
|
|
35
34
|
};
|
|
36
|
-
/** The recording folder
|
|
37
|
-
export const replayDir = () =>
|
|
35
|
+
/** The recording folder, `JEVWAY_REPLAY_DIR`: required whenever `JEVWAY_REPLAY` is set, so no run writes into a guessed folder. */
|
|
36
|
+
export const replayDir = () => {
|
|
37
|
+
const dir = process.env.JEVWAY_REPLAY_DIR?.trim();
|
|
38
|
+
if (!dir)
|
|
39
|
+
throw new Error('JEVWAY_REPLAY needs JEVWAY_REPLAY_DIR: the folder that holds the recordings.');
|
|
40
|
+
return resolve(dir);
|
|
41
|
+
};
|
|
38
42
|
const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
39
43
|
/**
|
|
40
44
|
* The request as it is matched: keys sorted at every depth, every image (an SDK `Image`, an `image_url` part, a
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A busy or flaky model API (5xx incl. 529 overload, 429, 499, timeouts, dropped connections): worth another try. */
|
|
2
|
+
const TRANSIENT_MODEL_ERROR = /^(5\d\d|429|499)\b|timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i;
|
|
3
|
+
/**
|
|
4
|
+
* Whether a model call failed for a reason that may pass on its own.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* isTransientModelError(new Error('529 We are currently experiencing high traffic')) // true
|
|
8
|
+
*/
|
|
9
|
+
export const isTransientModelError = (error) => error instanceof Error && TRANSIENT_MODEL_ERROR.test(error.message);
|
|
10
|
+
/**
|
|
11
|
+
* An `Ask` that tries again after a transient failure, waiting each of `delaysMs` in turn; any other failure, or the
|
|
12
|
+
* last one, is thrown as it came.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const ask = retryingAsk(createAsk(profile), { delaysMs: [1000, 3000] });
|
|
16
|
+
*/
|
|
17
|
+
export const retryingAsk = (ask, { delaysMs, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) => async (request) => {
|
|
18
|
+
for (const delay of delaysMs) {
|
|
19
|
+
try {
|
|
20
|
+
return await ask(request);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isTransientModelError(error))
|
|
24
|
+
throw error;
|
|
25
|
+
await wait(delay);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return ask(request);
|
|
29
|
+
};
|
|
@@ -44,7 +44,8 @@ export const buildFlows = (steps) => {
|
|
|
44
44
|
return [...flows.values()];
|
|
45
45
|
};
|
|
46
46
|
const parentOf = (name) => (name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : null);
|
|
47
|
-
|
|
47
|
+
/** A step's action as a person reads it: `Tap "Search"`, `Type into "Destination" → "Lisbon"`. */
|
|
48
|
+
export const describeAction = (step) => {
|
|
48
49
|
const verb = { CLICK: 'Tap', TYPE_TEXT: 'Type into', SELECT: 'Choose' }[step.action.operation] ?? step.action.operation;
|
|
49
50
|
return `${verb} "${step.action.label}"${step.action.value ? ` → "${step.action.value}"` : ''}`;
|
|
50
51
|
};
|
|
@@ -10,6 +10,9 @@ import { buildFlows, hotspotOf } from './journey-doc.js';
|
|
|
10
10
|
import { pickNextMove } from './pick-next-move.js';
|
|
11
11
|
import { renderViewer } from './render-viewer.js';
|
|
12
12
|
import { qaFindings, readScreenJudgments, screenQuestions, } from './screen-judgments.js';
|
|
13
|
+
import { screenFileName } from '../output/layout.js';
|
|
14
|
+
import { writeManifest } from '../output/manifest.js';
|
|
15
|
+
import { outsideHosts } from '../task/hosts.js';
|
|
13
16
|
const EXPLORE_GOAL = `Explore this site the way a first-time mobile visitor would, one screen at a time.
|
|
14
17
|
Open the next screen that matters most for the site's core user journeys: discovering, searching, comparing,
|
|
15
18
|
and evaluating its main offering. Prefer primary navigation, search, results, and detail screens over legal,
|
|
@@ -24,7 +27,7 @@ footer, social, app-download, or external links. Dismiss blocking banners. Never
|
|
|
24
27
|
* journey.report(); // screen s01 + Jev's suggested next tap
|
|
25
28
|
* await journey.step({ next: 4, flow: 'Search', insight: 'Search is a full-screen sheet' });
|
|
26
29
|
* await journey.autopilot({ screens: 5, flow: 'Search/Results' });
|
|
27
|
-
* journey.finish(); // → journey.json, index.html, screens
|
|
30
|
+
* journey.finish(); // → journey.json, index.html, manifest.json, screens/s01-home.png …
|
|
28
31
|
*/
|
|
29
32
|
export class JourneySession {
|
|
30
33
|
doc;
|
|
@@ -227,7 +230,8 @@ export class JourneySession {
|
|
|
227
230
|
continue;
|
|
228
231
|
}
|
|
229
232
|
const scope = this.#options.stayWithin ?? new URL(this.#options.startUrl).origin;
|
|
230
|
-
|
|
233
|
+
const landed = (await this.#tab.observe()).url;
|
|
234
|
+
if (!landed.startsWith(scope) || this.#outsideHosts(landed)) {
|
|
231
235
|
await this.#goBack();
|
|
232
236
|
this.#logMove('left_scope', move.action.label);
|
|
233
237
|
continue;
|
|
@@ -286,9 +290,15 @@ export class JourneySession {
|
|
|
286
290
|
return this.report();
|
|
287
291
|
}
|
|
288
292
|
// Observe → one Jev request (next step + screen understanding) → dedupe → screenshot → QA → save.
|
|
293
|
+
#outsideHosts(url) {
|
|
294
|
+
return this.#options.hosts && outsideHosts(url, this.#options.hosts);
|
|
295
|
+
}
|
|
289
296
|
async #capture(arrival) {
|
|
290
297
|
await this.#diagnostics.waitForIdle();
|
|
291
298
|
const page = await this.#settledPage();
|
|
299
|
+
const outside = this.#outsideHosts(page.url);
|
|
300
|
+
if (outside)
|
|
301
|
+
throw new Error(`The page is outside the server's hosts: ${outside}. Take \`back\` to return.`);
|
|
292
302
|
const seesScreen = (this.#options.screenshots ?? 'off') !== 'off';
|
|
293
303
|
const boxed = seesScreen && Boolean(this.#options.box);
|
|
294
304
|
const decision = await decide(this.#ask, {
|
|
@@ -320,7 +330,7 @@ export class JourneySession {
|
|
|
320
330
|
mergeFindings(this.#screen(existing).qa, findings);
|
|
321
331
|
}
|
|
322
332
|
else {
|
|
323
|
-
const screenshot =
|
|
333
|
+
const screenshot = screenFileName(id, judgments.type);
|
|
324
334
|
writeFileSync(join(this.outDir, screenshot), Buffer.from(await this.#tab.screenshot(), 'base64'));
|
|
325
335
|
this.doc.screens.push({
|
|
326
336
|
id,
|
|
@@ -433,6 +443,7 @@ export class JourneySession {
|
|
|
433
443
|
this.doc.flows = buildFlows(this.doc.steps);
|
|
434
444
|
writeFileSync(join(this.outDir, 'journey.json'), JSON.stringify(this.doc, null, 2));
|
|
435
445
|
writeFileSync(join(this.outDir, 'index.html'), renderViewer(this.doc));
|
|
446
|
+
writeManifest(this.outDir, 'journey');
|
|
436
447
|
}
|
|
437
448
|
}
|
|
438
449
|
// "https://site/#/" and "https://site/" are the same route.
|