jevway 0.1.2 → 0.3.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 +227 -149
- package/SETUP.md +212 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +21 -1
- package/dist/browser/snapshot.js +53 -0
- package/dist/browser/tab.js +59 -10
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +76 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +54 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +221 -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 +339 -0
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/jev/secrets.js +4 -3
- 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/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +27 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/task-tools.js +213 -0
- package/dist/mcp.js +10 -337
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +86 -0
- package/dist/output/manifest.js +86 -0
- package/dist/output/run-folder.js +80 -0
- package/dist/recording/describe-step.js +69 -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 +213 -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 +124 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +111 -0
- package/dist/task/run-task.js +332 -32
- package/dist/task/start-task.js +70 -11
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +10 -8
- package/skill/jevway/SKILL.md +54 -15
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
- package/INSTALL-FOR-AGENTS.md +0 -138
- package/dist/cli.js +0 -112
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import { callApi, cancelled, InboxError, membersOf } from './mail-api.js';
|
|
4
|
+
import { bodyText, extractCode, extractLinks, matchesFilters } from './message.js';
|
|
5
|
+
export { InboxError } from './mail-api.js';
|
|
6
|
+
/** The mail.tm-compatible providers, mail.tm first; the tools take a name, never a URL. */
|
|
7
|
+
export const PROVIDERS = { 'mail.tm': 'https://api.mail.tm', 'mail.gw': 'https://api.mail.gw' };
|
|
8
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
9
|
+
const FAILED_POLLS_LIMIT = 3;
|
|
10
|
+
/** An inbox from stored credentials, with the password hidden from serialization. */
|
|
11
|
+
export const inboxFrom = ({ address, password, api }) => Object.defineProperty({ address, api }, 'password', { value: password, enumerable: false });
|
|
12
|
+
/** `mail.tm` for its API URL; the URL itself for any other. */
|
|
13
|
+
export const providerOf = (api) => Object.keys(PROVIDERS).find((name) => PROVIDERS[name] === api) ?? api;
|
|
14
|
+
const summaryOf = (message) => ({
|
|
15
|
+
id: message.id ?? '',
|
|
16
|
+
from: message.from?.address ?? '',
|
|
17
|
+
subject: message.subject ?? '',
|
|
18
|
+
received_at: message.createdAt ?? '',
|
|
19
|
+
});
|
|
20
|
+
const receivedOf = (message) => {
|
|
21
|
+
const code = extractCode(message);
|
|
22
|
+
return { ...summaryOf(message), ...(code && { code }), links: extractLinks(message), text: bodyText(message) };
|
|
23
|
+
};
|
|
24
|
+
const createOn = async (api, client) => {
|
|
25
|
+
const domains = membersOf(await callApi(client, api, '/domains'));
|
|
26
|
+
const domain = domains.find((entry) => entry.isActive !== false)?.domain;
|
|
27
|
+
if (!domain)
|
|
28
|
+
throw new InboxError('provider_rejected', `${api} offers no active domain`, 'Try the other provider.');
|
|
29
|
+
const address = `test-${randomBytes(5).toString('hex')}@${domain}`;
|
|
30
|
+
const password = randomBytes(18).toString('base64url');
|
|
31
|
+
await callApi(client, api, '/accounts', { method: 'POST', body: { address, password }, secrets: [password] });
|
|
32
|
+
return inboxFrom({ address, password, api });
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A new inbox on `api`, or on mail.tm and then, if mail.tm is down, offers no active domain or refuses the account,
|
|
36
|
+
* on mail.gw once. Both failing is `inbox_unavailable`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const inbox = await createInbox(); // { address: 'test-0a1b2c3d4e@<domain>', api: 'https://api.mail.tm' }
|
|
40
|
+
*/
|
|
41
|
+
export const createInbox = async ({ api, ...client } = {}) => {
|
|
42
|
+
if (api)
|
|
43
|
+
return createOn(api, client);
|
|
44
|
+
try {
|
|
45
|
+
return await createOn(PROVIDERS['mail.tm'], client);
|
|
46
|
+
}
|
|
47
|
+
catch (first) {
|
|
48
|
+
if (!(first instanceof InboxError))
|
|
49
|
+
throw first;
|
|
50
|
+
try {
|
|
51
|
+
return await createOn(PROVIDERS['mail.gw'], client);
|
|
52
|
+
}
|
|
53
|
+
catch (second) {
|
|
54
|
+
throw new InboxError('inbox_unavailable', `No inbox could be made: mail.tm failed (${first.message}), then mail.gw failed (${second.message}).`, 'Both disposable inbox providers are down or refusing new accounts. Ask the user for a test mailbox of their own.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const fetchToken = async (inbox, client, signal) => (await callApi(client, inbox.api, '/token', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: { address: inbox.address, password: inbox.password },
|
|
61
|
+
secrets: [inbox.password],
|
|
62
|
+
signal,
|
|
63
|
+
})).token;
|
|
64
|
+
const listWith = async (inbox, client, token, signal) => membersOf(await callApi(client, inbox.api, '/messages', { token, signal, secrets: [inbox.password] }));
|
|
65
|
+
const readWith = async (inbox, client, token, id, signal) => {
|
|
66
|
+
try {
|
|
67
|
+
return receivedOf(await callApi(client, inbox.api, `/messages/${encodeURIComponent(id)}`, { token, signal, secrets: [inbox.password] }));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof InboxError && error.status === 404) {
|
|
71
|
+
throw new InboxError('message_not_found', `No message "${id}" in ${inbox.address}`, 'List the inbox for its message ids.', 404);
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** The inbox's messages, newest first, without their bodies. */
|
|
77
|
+
export const listMessages = async (inbox, { signal, ...client } = {}) => (await listWith(inbox, client, await fetchToken(inbox, client, signal), signal)).map(summaryOf);
|
|
78
|
+
/** One message in full, with its code and links. */
|
|
79
|
+
export const readMessage = async (inbox, id, { signal, ...client } = {}) => readWith(inbox, client, await fetchToken(inbox, client, signal), id, signal);
|
|
80
|
+
const defaultDelay = async (ms, signal) => {
|
|
81
|
+
try {
|
|
82
|
+
await sleep(ms, undefined, { signal });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw cancelled();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Polls the inbox now and every 3 s until the newest message matching `from` and `subject` arrives, then reads it
|
|
90
|
+
* in full; undefined once `timeoutMs` has passed (with one last poll at the deadline). A 401 gets a new token; 3
|
|
91
|
+
* failed polls in a row end the wait with the provider's error. Aborting `signal` stops it with `cancelled`.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* await waitForMessage(inbox, { from: 'staylist', timeoutMs: 120_000 }) // { code: '482913', links: [...], … }
|
|
95
|
+
*/
|
|
96
|
+
export const waitForMessage = async (inbox, { from, subject, timeoutMs, signal, onPoll, ...client }) => {
|
|
97
|
+
const now = client.now ?? Date.now;
|
|
98
|
+
const delay = client.delay ?? defaultDelay;
|
|
99
|
+
const started = now();
|
|
100
|
+
let token = await fetchToken(inbox, client, signal);
|
|
101
|
+
let failures = 0;
|
|
102
|
+
for (;;) {
|
|
103
|
+
if (signal?.aborted)
|
|
104
|
+
throw cancelled();
|
|
105
|
+
onPoll?.(now() - started);
|
|
106
|
+
try {
|
|
107
|
+
const match = (await listWith(inbox, client, token, signal)).find((message) => matchesFilters(message, { from, subject }));
|
|
108
|
+
if (match?.id)
|
|
109
|
+
return await readWith(inbox, client, token, match.id, signal);
|
|
110
|
+
failures = 0;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof InboxError) || error.code === 'cancelled' || error.code === 'message_not_found')
|
|
114
|
+
throw error;
|
|
115
|
+
if (++failures >= FAILED_POLLS_LIMIT)
|
|
116
|
+
throw error;
|
|
117
|
+
if (error.status === 401) {
|
|
118
|
+
token = await fetchToken(inbox, client, signal);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const remaining = started + timeoutMs - now();
|
|
123
|
+
if (remaining <= 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
await delay(Math.min(POLL_INTERVAL_MS, remaining), signal);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failed inbox call, with what to do in `hint`. Codes: `provider_unavailable` (network, 5xx, 429),
|
|
3
|
+
* `provider_rejected` (another 4xx), `inbox_unavailable` (both providers failed), `unknown_inbox`,
|
|
4
|
+
* `message_not_found`, `cancelled`.
|
|
5
|
+
*/
|
|
6
|
+
export class InboxError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
hint;
|
|
9
|
+
status;
|
|
10
|
+
constructor(code, message, hint, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.hint = hint;
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const RETRY_HINT = 'The provider is busy or down; try again in a minute, or ask the user for a test mailbox of their own.';
|
|
18
|
+
const scrub = (text, secrets) => secrets.filter(Boolean).reduce((clean, secret) => clean.split(secret).join('[secret]'), text);
|
|
19
|
+
export const cancelled = () => new InboxError('cancelled', 'Stopped waiting: cancelled by the caller.', 'Wait again when you need the email.');
|
|
20
|
+
/**
|
|
21
|
+
* One call to a mail.tm-compatible API, JSON in and out. Failures are `InboxError`s whose text never holds `secrets`
|
|
22
|
+
* (the password and token), even when the provider echoes them.
|
|
23
|
+
*/
|
|
24
|
+
export const callApi = async (client, api, path, { method = 'GET', body, token, signal, secrets = [] } = {}) => {
|
|
25
|
+
const fetch = client.fetch ?? globalThis.fetch;
|
|
26
|
+
const where = `${method} ${api}${path}`;
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await fetch(`${api}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: {
|
|
32
|
+
accept: 'application/ld+json',
|
|
33
|
+
...(body && { 'content-type': 'application/json' }),
|
|
34
|
+
...(token && { authorization: `Bearer ${token}` }),
|
|
35
|
+
},
|
|
36
|
+
body: body && JSON.stringify(body),
|
|
37
|
+
signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
throw cancelled();
|
|
43
|
+
throw new InboxError('provider_unavailable', `${where} failed: ${scrub(error.message, [...secrets, token ?? ''])}`, RETRY_HINT);
|
|
44
|
+
}
|
|
45
|
+
if (response.ok)
|
|
46
|
+
return (await response.json());
|
|
47
|
+
const detail = scrub((await response.text().catch(() => '')).slice(0, 300), [...secrets, token ?? '']);
|
|
48
|
+
const transient = response.status >= 500 || response.status === 429;
|
|
49
|
+
throw new InboxError(transient ? 'provider_unavailable' : 'provider_rejected', `${where} → ${response.status} ${detail}`.trim(), transient ? RETRY_HINT : 'The provider refused the request; the message says why.', response.status);
|
|
50
|
+
};
|
|
51
|
+
/** A collection as `{ 'hydra:member': [...] }` (ld+json) or a bare array (plain JSON). */
|
|
52
|
+
export const membersOf = (collection) => Array.isArray(collection) ? collection : (collection['hydra:member'] ?? []);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const EXCERPT_CHARS = 600;
|
|
2
|
+
const htmlOf = (message) => [message.html ?? []].flat().join('\n');
|
|
3
|
+
const stripHtml = (html) => html
|
|
4
|
+
.replace(/<(style|script)[\s\S]*?<\/\1>/gi, '')
|
|
5
|
+
.replace(/<[^>]+>/g, ' ')
|
|
6
|
+
.replace(/\s+/g, ' ')
|
|
7
|
+
.trim();
|
|
8
|
+
/** The text part, or the html part without tags when there is no text. */
|
|
9
|
+
export const bodyText = (message) => message.text || stripHtml(htmlOf(message));
|
|
10
|
+
/**
|
|
11
|
+
* The first standalone 4–8 digit number in subject and body: not part of a longer number, a decimal, a date or an id.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* extractCode({ subject: 'Welcome', text: 'Your code is 482913.' }) // '482913'
|
|
15
|
+
*/
|
|
16
|
+
export const extractCode = (message) => `${message.subject ?? ''}\n${bodyText(message)}`.match(/(?<![\w.,/-])\d{4,8}(?!\d|[.,/-]\d)/)?.[0];
|
|
17
|
+
// Doctype and namespace URLs and images (logos, tracking pixels) are never the link to follow.
|
|
18
|
+
const NOT_A_LINK = /^https?:\/\/www\.w3\.org\/|\.(png|jpe?g|gif|svg|webp|ico)(\?|$)/i;
|
|
19
|
+
/** Unique http(s) URLs a person could follow, from the text and html parts, `&` decoded. */
|
|
20
|
+
export const extractLinks = (message) => {
|
|
21
|
+
const urls = `${message.text ?? ''}\n${htmlOf(message)}`.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? [];
|
|
22
|
+
return [...new Set(urls.map((url) => url.replace(/&/g, '&')))].filter((url) => !NOT_A_LINK.test(url));
|
|
23
|
+
};
|
|
24
|
+
/** Whether the sender (name or address) and subject contain the filters, ignoring case; a missing filter passes. */
|
|
25
|
+
export const matchesFilters = (message, { from, subject }) => {
|
|
26
|
+
const sender = `${message.from?.name ?? ''} ${message.from?.address ?? ''}`.toLowerCase();
|
|
27
|
+
return ((!from || sender.includes(from.toLowerCase())) &&
|
|
28
|
+
(!subject || (message.subject ?? '').toLowerCase().includes(subject.toLowerCase())));
|
|
29
|
+
};
|
|
30
|
+
/** `text` with whitespace folded, cut at 600 characters with an ellipsis. */
|
|
31
|
+
export const excerpt = (text) => {
|
|
32
|
+
const folded = text.replace(/\s+/g, ' ').trim();
|
|
33
|
+
return folded.length <= EXCERPT_CHARS ? folded : `${folded.slice(0, EXCERPT_CHARS).trimEnd()}…`;
|
|
34
|
+
};
|
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
|
+
};
|
package/dist/jev/secrets.js
CHANGED
|
@@ -29,15 +29,16 @@ const isImagePart = (value) => value.type === 'image_url';
|
|
|
29
29
|
* caller marked secret never reaches the model or any output, including when the page echoes it (upper-cased, in an
|
|
30
30
|
* address). Matching is case-insensitive and takes the longest form first, in one pass, so a marker is never scrubbed
|
|
31
31
|
* again. Every non-empty value counts, even a short one: a garbled text is the safe failure, a leaked secret is not.
|
|
32
|
-
*
|
|
32
|
+
* `more` adds secrets that are not supplied values, each under the key it stands for (an authenticator secret's
|
|
33
|
+
* base32, every code typed from it). With nothing marked it is the identity.
|
|
33
34
|
*
|
|
34
35
|
* @example
|
|
35
36
|
* const scrub = scrubberFor({ token: 'ghp_x1' }, ['token']);
|
|
36
37
|
* scrub({ page_text: 'Token GHP_X1 created' }) // { page_text: 'Token [secret:token] created' }
|
|
37
38
|
*/
|
|
38
|
-
export const scrubberFor = (values, secretKeys = []) => {
|
|
39
|
+
export const scrubberFor = (values, secretKeys = [], more = []) => {
|
|
39
40
|
const keyByForm = new Map();
|
|
40
|
-
for (const [key, value] of secretEntries(values, secretKeys)) {
|
|
41
|
+
for (const [key, value] of [...secretEntries(values, secretKeys), ...more.filter(([, value]) => value.trim() !== '')]) {
|
|
41
42
|
for (const form of formsOf(value))
|
|
42
43
|
if (form && !keyByForm.has(form.toLowerCase()))
|
|
43
44
|
keyByForm.set(form.toLowerCase(), key);
|
|
@@ -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.
|