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,40 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const NOTIFY_TIMEOUT_MS = 5000;
|
|
3
|
+
const OFF = new Set(['off', '0', 'false', 'no']);
|
|
4
|
+
const runProgram = (file, args) => new Promise((resolve) => execFile(file, args, { timeout: NOTIFY_TIMEOUT_MS }, () => resolve(undefined)));
|
|
5
|
+
/**
|
|
6
|
+
* An AppleScript string literal for `text`: quotes and backslashes escaped.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* appleScriptString('Pass the "unusual traffic" check') // '"Pass the \\"unusual traffic\\" check"'
|
|
10
|
+
*/
|
|
11
|
+
export const appleScriptString = (text) => `"${text.replace(/[\\"]/g, '\\$&')}"`;
|
|
12
|
+
/**
|
|
13
|
+
* The desktop notifier for this platform, titled "jevway": `osascript` on macOS, `notify-send` on Linux (nothing when
|
|
14
|
+
* it is not installed). Undefined on Windows and when `JEVWAY_NOTIFY` is `off` (or `0`, `false`, `no`).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* desktopNotifier()?.('Sign in to github.com in Chrome');
|
|
18
|
+
*/
|
|
19
|
+
export const desktopNotifier = ({ env = process.env, platform = process.platform, run = runProgram, } = {}) => {
|
|
20
|
+
if (OFF.has(env.JEVWAY_NOTIFY?.trim().toLowerCase() ?? ''))
|
|
21
|
+
return undefined;
|
|
22
|
+
const command = (message) => {
|
|
23
|
+
if (platform === 'darwin')
|
|
24
|
+
return ['osascript', ['-e', `display notification ${appleScriptString(message)} with title "jevway"`]];
|
|
25
|
+
if (platform === 'linux')
|
|
26
|
+
return ['notify-send', ['jevway', message]];
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
if (!command(''))
|
|
30
|
+
return undefined;
|
|
31
|
+
return (message) => {
|
|
32
|
+
const [file, args] = command(message);
|
|
33
|
+
try {
|
|
34
|
+
Promise.resolve(run(file, args)).catch(() => undefined);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A notification is a courtesy: a missing program or a failed one never reaches the run.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every phrase jevway says to the person at a wall: the desktop notification, the countdown the agent relays, and the
|
|
3
|
+
* story line once it cleared. Built from the wall's kind, host and name only, never from the page's text.
|
|
4
|
+
*/
|
|
5
|
+
// '"unusual traffic" page' → 'the "unusual traffic" check'; 'Cloudflare' → 'the Cloudflare check'.
|
|
6
|
+
const checkName = (check) => `the ${check.replace(/ page$/, '')} check`;
|
|
7
|
+
// What the person does, as a phrase that follows "to" or starts a sentence once capitalised.
|
|
8
|
+
const task = (wall) => {
|
|
9
|
+
switch (wall.kind) {
|
|
10
|
+
case 'bot_check':
|
|
11
|
+
return `pass ${checkName(wall.check)} in the Chrome window`;
|
|
12
|
+
case 'sign_in':
|
|
13
|
+
return `sign in to ${wall.host} in Chrome`;
|
|
14
|
+
case 'sso':
|
|
15
|
+
return `finish signing in with ${wall.provider} in Chrome`;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The desktop notification's body.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* notificationText({ kind: 'sign_in', host: 'github.com' }) // 'Sign in to github.com in Chrome'
|
|
23
|
+
*/
|
|
24
|
+
export const notificationText = (wall) => {
|
|
25
|
+
const text = task(wall);
|
|
26
|
+
return `${text[0].toUpperCase()}${text.slice(1)}`;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The countdown line the run tells while it waits.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* waitingText({ kind: 'bot_check', check: 'Cloudflare' }, 45) // 'Waiting for you to pass the Cloudflare check in the Chrome window (45 s left)'
|
|
33
|
+
*/
|
|
34
|
+
export const waitingText = (wall, secondsLeft) => `Waiting for you to ${task(wall)} (${secondsLeft} s left)`;
|
|
35
|
+
/**
|
|
36
|
+
* The step a run's history and story carry once the person got past the wall.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* passedText({ kind: 'sso', provider: 'Google' }) // 'A person finished signing in with Google'
|
|
40
|
+
*/
|
|
41
|
+
export const passedText = (wall) => {
|
|
42
|
+
switch (wall.kind) {
|
|
43
|
+
case 'bot_check':
|
|
44
|
+
return `A person passed ${checkName(wall.check)}`;
|
|
45
|
+
case 'sign_in':
|
|
46
|
+
return `A person signed in to ${wall.host}`;
|
|
47
|
+
case 'sso':
|
|
48
|
+
return `A person finished signing in with ${wall.provider}`;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extname, join, resolve } from 'node:path';
|
|
3
|
+
const SLUG_CHARS = 40;
|
|
4
|
+
const unchanged = (text) => text;
|
|
5
|
+
/** Where jevway writes runs and journeys: `$JEVWAY_OUT_DIR`, else `./.jevway`, resolved against `cwd`. */
|
|
6
|
+
export const outRoot = (env = process.env, cwd = process.cwd()) => resolve(cwd, env.JEVWAY_OUT_DIR || '.jevway');
|
|
7
|
+
/** Creates the root with a `.gitignore` of `*`, written only when absent so an owner's own rules stand. */
|
|
8
|
+
export const ensureOutRoot = (root) => {
|
|
9
|
+
mkdirSync(root, { recursive: true });
|
|
10
|
+
const ignore = join(root, '.gitignore');
|
|
11
|
+
if (!existsSync(ignore))
|
|
12
|
+
writeFileSync(ignore, '*\n');
|
|
13
|
+
return root;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Lowercase ASCII words joined by hyphens, at most 40 characters cut at a word; `run` when nothing is left.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* slugify('Réserver à São Paulo — 2 guests!') // 'reserver-a-sao-paulo-2-guests'
|
|
20
|
+
*/
|
|
21
|
+
export const slugify = (text, max = SLUG_CHARS) => {
|
|
22
|
+
const words = text
|
|
23
|
+
.normalize('NFKD')
|
|
24
|
+
.replace(/[̀-ͯ]/g, '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '');
|
|
28
|
+
if (!words)
|
|
29
|
+
return 'run';
|
|
30
|
+
if (words.length <= max)
|
|
31
|
+
return words;
|
|
32
|
+
const cut = words.slice(0, max);
|
|
33
|
+
const lastBreak = cut.lastIndexOf('-');
|
|
34
|
+
return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
35
|
+
};
|
|
36
|
+
/** A slug that never holds a secret value: scrubbed before slugging, and the slug scrubbed and slugged once more. */
|
|
37
|
+
export const safeSlug = (text, scrub = unchanged) => slugify(scrub(slugify(scrub(text))));
|
|
38
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
39
|
+
/** Local time as `2026-11-02_0905`: folders are browsed by people, and sort by start time. */
|
|
40
|
+
export const folderStamp = (at) => `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}_${pad(at.getHours())}${pad(at.getMinutes())}`;
|
|
41
|
+
/** `<stamp>_<goal-slug>_<first 4 of the session id>`. */
|
|
42
|
+
export const runFolderName = ({ at, goal, id, scrub }) => `${folderStamp(at)}_${safeSlug(goal, scrub)}_${id.slice(0, 4)}`;
|
|
43
|
+
/** `<stamp>_<host>_<first 4 of the journey id>`; a port's colon becomes a hyphen. */
|
|
44
|
+
export const journeyFolderName = ({ at, host, id }) => `${folderStamp(at)}_${host.toLowerCase().replace(/[^a-z0-9.-]+/g, '-')}_${id.slice(0, 4)}`;
|
|
45
|
+
/** `<stamp>_<host>_<first 4 of the look's id>`: a look's own folder under `looks/`, named like a journey's. */
|
|
46
|
+
export const lookFolderName = journeyFolderName;
|
|
47
|
+
const FILE_NAME_CHARS = 120;
|
|
48
|
+
/**
|
|
49
|
+
* A name from outside (a site's suggested download name, a URL path) as one plain file name: no folders, no leading dots,
|
|
50
|
+
* no control or reserved characters, at most 120 characters with its extension kept; `fallback` when nothing is left.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* safeFileName('../../.ssh/config', 'cv') // 'config'
|
|
54
|
+
*/
|
|
55
|
+
export const safeFileName = (name, fallback) => {
|
|
56
|
+
const plain = (name.split(/[/\\]/).pop() ?? '')
|
|
57
|
+
.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_')
|
|
58
|
+
.replace(/^[.\s]+/, '')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!plain)
|
|
61
|
+
return fallback;
|
|
62
|
+
if (plain.length <= FILE_NAME_CHARS)
|
|
63
|
+
return plain;
|
|
64
|
+
const extension = extname(plain).slice(0, 16);
|
|
65
|
+
return plain.slice(0, FILE_NAME_CHARS - extension.length) + extension;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* A path in `dir` for `name` that no file holds yet: `cv.pdf`, then `cv-2.pdf`, `cv-3.pdf`.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* uniqueFileName('/run/downloads', 'invoice.pdf') // '/run/downloads/invoice-2.pdf' when invoice.pdf is taken
|
|
72
|
+
*/
|
|
73
|
+
export const uniqueFileName = (dir, name) => {
|
|
74
|
+
const extension = extname(name);
|
|
75
|
+
const base = name.slice(0, name.length - extension.length);
|
|
76
|
+
let path = join(dir, name);
|
|
77
|
+
for (let n = 2; existsSync(path); n++)
|
|
78
|
+
path = join(dir, `${base}-${n}${extension}`);
|
|
79
|
+
return path;
|
|
80
|
+
};
|
|
81
|
+
/** A recorded step's shot, relative to its folder: `steps/01-type-destination.jpg`. */
|
|
82
|
+
export const stepFileName = (n, slug) => `steps/${pad(n)}-${slug || 'step'}.jpg`;
|
|
83
|
+
/** A call's end page, relative to its folder: `calls/02-needs-input.jpg`. */
|
|
84
|
+
export const callShotName = (n, status) => `calls/${pad(n)}-${slugify(status)}.jpg`;
|
|
85
|
+
/** A journey screen's screenshot, relative to its folder: `screens/s01-home.png`. */
|
|
86
|
+
export const screenFileName = (id, type) => `screens/${id}-${slugify(type)}.png`;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
const MANIFEST = 'manifest.json';
|
|
4
|
+
/** Every file under `dir`, relative and sorted; dotfiles and dot-folders (`.frames` while encoding) are left out. */
|
|
5
|
+
const filesUnder = (dir, base = dir) => readdirSync(dir, { withFileTypes: true })
|
|
6
|
+
.filter((entry) => !entry.name.startsWith('.'))
|
|
7
|
+
.flatMap((entry) => (entry.isDirectory() ? filesUnder(join(dir, entry.name), base) : [relative(base, join(dir, entry.name))]))
|
|
8
|
+
.map((path) => path.split('\\').join('/'))
|
|
9
|
+
.sort();
|
|
10
|
+
const readJson = (file) => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const oneLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
19
|
+
/** What the folder's own records say about some of its files: step shots from `summary.json`, screens from `journey.json`. */
|
|
20
|
+
const notesIn = (dir, kind) => {
|
|
21
|
+
const notes = new Map();
|
|
22
|
+
if (kind === 'run') {
|
|
23
|
+
const steps = readJson(join(dir, 'summary.json'))?.steps ?? [];
|
|
24
|
+
steps.forEach((step, i) => {
|
|
25
|
+
if (!step.file || !step.description)
|
|
26
|
+
return;
|
|
27
|
+
const finished = step.description.startsWith('Finished:');
|
|
28
|
+
notes.set(step.file, finished ? step.description : `Before step ${i + 1}, its target outlined: ${step.description}`);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const screens = readJson(join(dir, 'journey.json'))?.screens ?? [];
|
|
33
|
+
for (const screen of screens)
|
|
34
|
+
if (screen.screenshot)
|
|
35
|
+
notes.set(screen.screenshot, `Screen ${screen.id} (${screen.type ?? 'unknown'}): ${screen.title ?? ''}`);
|
|
36
|
+
}
|
|
37
|
+
return notes;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* One file as a manifest line: its kind from its name, and a description from the folder's records where they say more.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* describeFile('calls/02-needs-input.jpg', 'run', new Map()) // { path, kind: 'screenshot', description: 'The page as call 2 ended: needs_input' }
|
|
44
|
+
*/
|
|
45
|
+
export const describeFile = (path, kind, notes) => {
|
|
46
|
+
const line = (fileKind, description) => ({ path, kind: fileKind, description: oneLine(description) });
|
|
47
|
+
const call = /^calls\/(\d+)-(.+)\.jpg$/.exec(path);
|
|
48
|
+
if (path === MANIFEST)
|
|
49
|
+
return line('manifest', "This list of the folder's files");
|
|
50
|
+
if (path === 'index.html') {
|
|
51
|
+
return kind === 'run'
|
|
52
|
+
? line('page', 'The run page for people: the video with every step on a timeline, and how each call ended')
|
|
53
|
+
: line('page', 'The journey map viewer: screens, flows and QA findings');
|
|
54
|
+
}
|
|
55
|
+
if (path === 'summary.json')
|
|
56
|
+
return line('summary', "Each call's summary (status, reason, steps, cost), plus the recorded steps when recorded");
|
|
57
|
+
if (path === 'journey.json')
|
|
58
|
+
return line('journey', 'The journey map as data: screens, steps, flows and QA findings');
|
|
59
|
+
if (path === 'run.mp4')
|
|
60
|
+
return line('video', 'Screencast of the whole session (idle stretches shortened)');
|
|
61
|
+
if (path === 'final.jpg')
|
|
62
|
+
return line('screenshot', 'The page as the latest call ended (viewport, JPEG)');
|
|
63
|
+
if (call)
|
|
64
|
+
return line('screenshot', `The page as call ${Number(call[1])} ended: ${call[2].replace(/-/g, '_')}`);
|
|
65
|
+
if (path.startsWith('steps/'))
|
|
66
|
+
return line('step', notes.get(path) ?? 'A recorded step, before its action');
|
|
67
|
+
if (path.startsWith('downloads/'))
|
|
68
|
+
return line('download', `Downloaded by a step of the run: ${path.slice('downloads/'.length)}`);
|
|
69
|
+
if (path.startsWith('uploads/'))
|
|
70
|
+
return line('upload', `Fetched from a URL the caller gave in files, then uploaded: ${path.slice('uploads/'.length)}`);
|
|
71
|
+
if (path.startsWith('looks/') && path.endsWith('.pdf'))
|
|
72
|
+
return line('pdf', `The page as a PDF, from a look: ${path}`);
|
|
73
|
+
if (path.startsWith('screens/'))
|
|
74
|
+
return line('screen', notes.get(path) ?? 'A journey screen');
|
|
75
|
+
return line('file', 'Not described');
|
|
76
|
+
};
|
|
77
|
+
/** Writes `manifest.json` for what `dir` holds now, itself included. */
|
|
78
|
+
export const writeManifest = (dir, kind) => {
|
|
79
|
+
const notes = notesIn(dir, kind);
|
|
80
|
+
const paths = [...new Set([...filesUnder(dir), MANIFEST])].sort();
|
|
81
|
+
const manifest = { kind, files: paths.map((path) => describeFile(path, kind, notes)) };
|
|
82
|
+
writeFileSync(join(dir, MANIFEST), JSON.stringify(manifest, null, 1));
|
|
83
|
+
return join(dir, MANIFEST);
|
|
84
|
+
};
|
|
85
|
+
/** Reads a folder's `manifest.json`. */
|
|
86
|
+
export const readManifest = (dir) => JSON.parse(readFileSync(join(dir, MANIFEST), 'utf8'));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { callShotName, ensureOutRoot, lookFolderName, slugify } from './layout.js';
|
|
4
|
+
import { writeManifest } from './manifest.js';
|
|
5
|
+
/** The files in `<dir>/downloads/`, by name. */
|
|
6
|
+
const downloadsIn = (dir) => {
|
|
7
|
+
const folder = join(dir, 'downloads');
|
|
8
|
+
if (!existsSync(folder))
|
|
9
|
+
return [];
|
|
10
|
+
return readdirSync(folder, { withFileTypes: true })
|
|
11
|
+
.filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
|
|
12
|
+
.map((entry) => join(folder, entry.name))
|
|
13
|
+
.sort()
|
|
14
|
+
.map((path) => ({ path, name: path.slice(folder.length + 1), bytes: statSync(path).size }));
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Writes what one call leaves in its run folder: its end page as `calls/NN-<status>.jpg` and `final.jpg` (a failed
|
|
18
|
+
* capture removes an older `final.jpg`, so it never shows another call's page), `summary.json` unless the recording
|
|
19
|
+
* wrote its own, and the manifest. Returns the folder's files.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* writeCall({ dir, call: 2, status: 'done', shot, summaries, recorded: false }) // { run_dir, screenshot: '…/final.jpg', manifest }
|
|
23
|
+
*/
|
|
24
|
+
export const writeCall = ({ dir, call, status, shot, summaries, recorded, }) => {
|
|
25
|
+
const root = resolve(dir);
|
|
26
|
+
mkdirSync(root, { recursive: true });
|
|
27
|
+
const final = join(root, 'final.jpg');
|
|
28
|
+
if (shot) {
|
|
29
|
+
const callShot = join(root, callShotName(call, status));
|
|
30
|
+
mkdirSync(dirname(callShot), { recursive: true });
|
|
31
|
+
writeFileSync(callShot, shot);
|
|
32
|
+
copyFileSync(callShot, final);
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
rmSync(final, { force: true });
|
|
36
|
+
// A recording that failed before writing its own leaves the folder without one.
|
|
37
|
+
if (!recorded || !existsSync(join(root, 'summary.json')))
|
|
38
|
+
writeFileSync(join(root, 'summary.json'), JSON.stringify({ summaries }, null, 1));
|
|
39
|
+
const manifest = writeManifest(root, 'run');
|
|
40
|
+
const page = join(root, 'index.html');
|
|
41
|
+
const video = join(root, 'run.mp4');
|
|
42
|
+
const downloads = downloadsIn(root);
|
|
43
|
+
return {
|
|
44
|
+
run_dir: root,
|
|
45
|
+
...(shot && { screenshot: final }),
|
|
46
|
+
...(recorded && existsSync(page) && { page }),
|
|
47
|
+
...(recorded && existsSync(video) && { video }),
|
|
48
|
+
manifest,
|
|
49
|
+
...(downloads.length && { downloads }),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Saves a session look's PDF as `looks/NN-<title>.pdf` in its run folder and refreshes the manifest. Returns its path.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* writeRunPdf({ dir, pdf, title: 'Checkout' }) // '<dir>/looks/01-checkout.pdf'
|
|
57
|
+
*/
|
|
58
|
+
export const writeRunPdf = ({ dir, pdf, title }) => {
|
|
59
|
+
const looks = join(resolve(dir), 'looks');
|
|
60
|
+
mkdirSync(looks, { recursive: true });
|
|
61
|
+
const n = readdirSync(looks).filter((name) => name.endsWith('.pdf')).length + 1;
|
|
62
|
+
const path = join(looks, `${String(n).padStart(2, '0')}-${slugify(title)}.pdf`);
|
|
63
|
+
writeFileSync(path, pdf);
|
|
64
|
+
writeManifest(resolve(dir), 'run');
|
|
65
|
+
return path;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Saves the PDF of a look with no session as `looks/<stamp>_<host>_<id4>/page.pdf` under the output root. Returns its path.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* writeLookPdf({ root, pdf, url: 'https://stays.example/pricing', at, id }) // '<root>/looks/2026-11-02_0905_stays.example_ab12/page.pdf'
|
|
72
|
+
*/
|
|
73
|
+
export const writeLookPdf = ({ root, pdf, url, at, id }) => {
|
|
74
|
+
const host = URL.canParse(url) ? new URL(url).host : '';
|
|
75
|
+
const dir = join(ensureOutRoot(root), 'looks', lookFolderName({ at, host: host || 'page', id }));
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const path = join(dir, 'page.pdf');
|
|
78
|
+
writeFileSync(path, pdf);
|
|
79
|
+
return path;
|
|
80
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The one wording of a step that people read: the recording's shots (what happens next) and a summary's story (what
|
|
2
|
+
// happened). Kept apart from the recording so the pages and the story share it without importing the recorder.
|
|
3
|
+
// Row labels carry a whole table row; a sentence needs its first words.
|
|
4
|
+
const shorten = (label, max = 70) => {
|
|
5
|
+
const text = label.replace(/\s+/g, ' ').trim();
|
|
6
|
+
return text.length > max ? `${text.slice(0, max).replace(/\s+\S*$/, '')}…` : text;
|
|
7
|
+
};
|
|
8
|
+
// Each verb as Jev is about to do it (`next`, the recording's step shots) and as done (`done`, a summary's story).
|
|
9
|
+
const VERBS = {
|
|
10
|
+
click: ['Click', 'Clicked'],
|
|
11
|
+
type: ['Type', 'Typed'],
|
|
12
|
+
choose: ['Choose', 'Chose'],
|
|
13
|
+
press: ['Press', 'Pressed'],
|
|
14
|
+
scroll: ['Scroll', 'Scrolled'],
|
|
15
|
+
wait: ['Wait', 'Waited'],
|
|
16
|
+
upload: ['Upload', 'Uploaded'],
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A step as one plain sentence for people reading a recording (`next`, what happens on this screen) or a summary's
|
|
20
|
+
* story (`done`, what happened).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* describeStep({ operation: 'TYPE_TEXT', element: 'Search traces', valueKey: 'search_query' })
|
|
24
|
+
* // 'Type the "search_query" value into "Search traces"'
|
|
25
|
+
* describeStep({ operation: 'CLICK', element: 'Search' }, 'done') // 'Clicked "Search"'
|
|
26
|
+
*/
|
|
27
|
+
export const describeStep = ({ operation, element = '', valueKey, fileKey, option }, tense = 'next') => {
|
|
28
|
+
const target = `"${shorten(element)}"`;
|
|
29
|
+
const verb = (name) => VERBS[name][tense === 'next' ? 0 : 1];
|
|
30
|
+
switch (operation) {
|
|
31
|
+
case 'CLICK':
|
|
32
|
+
return `${verb('click')} ${target}`;
|
|
33
|
+
case 'CLICK_AT':
|
|
34
|
+
return `${verb('click')} the control at ${target} by its on-screen position`;
|
|
35
|
+
case 'TYPE_TEXT':
|
|
36
|
+
return `${verb('type')} the "${valueKey}" value into ${target}`;
|
|
37
|
+
case 'UPLOAD':
|
|
38
|
+
return fileKey ? `${verb('upload')} the "${fileKey}" file into ${target}` : `${verb('upload')} a file into ${target}`;
|
|
39
|
+
case 'SELECT':
|
|
40
|
+
return option ? `${verb('choose')} "${option}" in ${target}` : `${verb('choose')} an option in ${target}`;
|
|
41
|
+
case 'PRESS_ENTER':
|
|
42
|
+
return `${verb('press')} Enter in ${target} to submit it`;
|
|
43
|
+
case 'PRESS_ESCAPE':
|
|
44
|
+
return `${verb('press')} Escape to close the open menu or popup`;
|
|
45
|
+
case 'SCROLL_DOWN':
|
|
46
|
+
return `${verb('scroll')} down to reveal more of the page`;
|
|
47
|
+
case 'SCROLL_UP':
|
|
48
|
+
return `${verb('scroll')} back up`;
|
|
49
|
+
case 'WAIT':
|
|
50
|
+
return `${verb('wait')} for the page to finish updating`;
|
|
51
|
+
// Says itself: "A person passed the Cloudflare check".
|
|
52
|
+
case 'WAIT_FOR_PERSON':
|
|
53
|
+
return element;
|
|
54
|
+
default:
|
|
55
|
+
return `${operation} ${target}`;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const PAST = new Map(Object.values(VERBS));
|
|
59
|
+
/**
|
|
60
|
+
* A recorded step's description (the `next` tense) in the past tense, as a sentence.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* pastTense('Type the "email" value into "Email"') // 'Typed the "email" value into "Email".'
|
|
64
|
+
*/
|
|
65
|
+
export const pastTense = (description) => {
|
|
66
|
+
const [verb] = description.split(' ', 1);
|
|
67
|
+
const told = PAST.has(verb) ? `${PAST.get(verb)}${description.slice(verb.length)}` : description;
|
|
68
|
+
return /[.!?]$/.test(told) ? told : `${told}.`;
|
|
69
|
+
};
|