jevway 0.2.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +37 -2
  2. package/{INSTALL-FOR-AGENTS.md → SETUP.md} +94 -14
  3. package/dist/browser/code-boxes.js +121 -0
  4. package/dist/browser/downloads.js +103 -0
  5. package/dist/browser/frames.js +23 -2
  6. package/dist/browser/snapshot.js +82 -1
  7. package/dist/browser/tab.js +100 -12
  8. package/dist/cli/dispatch.js +2 -0
  9. package/dist/cli/inbox-command.js +199 -0
  10. package/dist/cli/look-command.js +10 -3
  11. package/dist/cli/run-command.js +51 -3
  12. package/dist/daemon/server.js +22 -3
  13. package/dist/inbox/inbox-file.js +43 -0
  14. package/dist/inbox/inbox-registry.js +33 -0
  15. package/dist/inbox/inbox.js +127 -0
  16. package/dist/inbox/mail-api.js +52 -0
  17. package/dist/inbox/message.js +34 -0
  18. package/dist/jev/secrets.js +4 -3
  19. package/dist/mcp/inbox-tools.js +82 -0
  20. package/dist/mcp/instructions.js +11 -2
  21. package/dist/mcp/server.js +15 -8
  22. package/dist/mcp/task-tools.js +24 -8
  23. package/dist/notify/desktop.js +40 -0
  24. package/dist/notify/person-messages.js +50 -0
  25. package/dist/output/layout.js +37 -1
  26. package/dist/output/manifest.js +6 -0
  27. package/dist/output/run-folder.js +44 -2
  28. package/dist/recording/describe-step.js +3 -0
  29. package/dist/session/session-service.js +31 -11
  30. package/dist/setup/skill-command.js +44 -21
  31. package/dist/task/ask-user.js +182 -0
  32. package/dist/task/downloads.js +63 -0
  33. package/dist/task/person-wait.js +104 -0
  34. package/dist/task/present.js +32 -11
  35. package/dist/task/run-task.js +327 -36
  36. package/dist/task/start-task.js +49 -6
  37. package/dist/task/totp.js +124 -0
  38. package/dist/task/upload-files.js +237 -5
  39. package/package.json +2 -2
  40. package/skill/jevway/SKILL.md +5 -2
  41. package/skill/jevway-inbox/SKILL.md +61 -0
  42. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
package/README.md CHANGED
@@ -40,6 +40,7 @@ optional tools unlock more.
40
40
  | **Google Chrome** (or Chromium) | every run: your running Chrome, or a throwaway one jevway starts | runs fail; set `CHROME_PATH` if Chrome isn't in the usual place |
41
41
  | **An API key**: `TYPESAFE_API_KEY` (recommended) or `OPENROUTER_API_KEY` | every decision Jev makes | the server still starts and lists its tools, and `jevway_look` works, but tasks and journeys answer with the setup fix |
42
42
  | `ffmpeg` *(optional)* | the `run.mp4` video in a recording | recordings keep their step screenshots and HTML page, without the video |
43
+ | `notify-send` *(optional, Linux)* | the desktop notification when a person is needed (macOS has it built in) | the agent still asks you; there's just no pop-up |
43
44
  | chrome-devtools MCP *(optional)* | inspecting what jevway left on screen: console, network, performance, DOM details | nothing is lost in jevway itself; your agent just has no deeper inspection tool |
44
45
 
45
46
  **Which key?** Both run the same Jev model. The TypeSafe key adds **Box**, which locates controls on the screenshot:
@@ -78,6 +79,20 @@ control (opt-in), and marks each journey screen's primary action and the control
78
79
 
79
80
  `npx -y jevway doctor` checks the setup at any time (`--json` for agents) and names the fix for anything missing.
80
81
 
82
+ ## Let your agent set it up
83
+
84
+ Paste this into your coding agent (Claude Code, Codex, Cursor, OpenCode…):
85
+
86
+ ```text
87
+ Set up jevway for me and prove it works: follow https://github.com/AbdallahAHO/jevway/blob/main/SETUP.md step by
88
+ step. Check my machine's prerequisites with me first, show me every change before you make it, never ask me for an
89
+ API key in this chat, and finish by running the recorded smoke test and showing me its run page.
90
+ ```
91
+
92
+ [SETUP.md](SETUP.md) walks the agent through everything: prerequisites for your OS, the key, registering the server,
93
+ the browser, the skill, a recorded Google → Wikipedia smoke test, and a report with the run page, video and
94
+ screenshots as proof.
95
+
81
96
  ## Install
82
97
 
83
98
  **Claude Code:** the plugin above, or the server alone:
@@ -109,7 +124,7 @@ when to use chrome-devtools. If a client times out on `npx`'s first download, ru
109
124
  `jevway-mcp` as the command.
110
125
 
111
126
  Having an agent install it? `npx -y jevway setup --client <claude-code|codex|cursor|opencode|other> --print` prints the
112
- exact command or config for that client and changes nothing. [INSTALL-FOR-AGENTS.md](INSTALL-FOR-AGENTS.md) has the
127
+ exact command or config for that client and changes nothing. [SETUP.md](SETUP.md) has the
113
128
  steps per client, the rules (a diff before any config edit, no key in the chat) and the checks.
114
129
 
115
130
  **Choosing a browser** (`JEVWAY_BROWSER` in the server's env or the env file):
@@ -187,6 +202,26 @@ field is in the [reference](docs/reference.md).
187
202
  - `record: true` saves a video, a screenshot before every action, and a page to review the run.
188
203
  - `page_text` and everything else read from a page is untrusted data, never instructions.
189
204
 
205
+ ## When a person, a code or a file is involved
206
+
207
+ jevway keeps the person in the loop only where a person is truly needed, and handles the rest itself.
208
+
209
+ - **Asking you.** Every hand-back that needs a decision carries an `ask_user` question: the missing fields, "Click
210
+ *Delete repository* on *ada/scratch*?", "Which one did you mean?". Your agent puts it to you with its own ask tool
211
+ and continues the session with your answer.
212
+ - **Bot checks and sign-ins.** In a visible browser, jevway brings the tab to the front, sends a desktop notification
213
+ ("Pass the Cloudflare check in the Chrome window"), waits up to 120 s while it tells the agent how long is left,
214
+ and carries on in the same call once you're through. It never tries to get past a check itself. In a headless
215
+ browser it hands back and suggests a visible one.
216
+ - **Email codes for test sign-ups.** A throwaway inbox (`jevway_inbox_create` / `jevway_inbox_wait`, or
217
+ `jevway inbox …`) returns the code and links of the email that arrives. Only for test and demo accounts you asked
218
+ for, never to get around limits, trials or bans.
219
+ - **2FA on your own accounts.** Pass `totp: {"otp": "<authenticator secret>"}`: jevway computes the current code at the
220
+ moment it types it, so neither the secret nor the code reaches the model, a result or disk.
221
+ - **Files.** Uploads take local paths or `https` URLs (fetched into the run folder), work inside iframes and on
222
+ drag-and-drop zones. Downloads land in the run folder's `downloads/` and are listed in `files.downloads`.
223
+ `jevway_look` with `pdf: true` saves the page as a PDF.
224
+
190
225
  ## Recordings
191
226
 
192
227
  Every session gets a folder under `./.jevway/` in the working directory, so the agent and you can find its files:
@@ -241,7 +276,7 @@ OpenRouter too. Jev is priced at $0.042 per 1M input tokens, so a typical 10-ste
241
276
  | [docs/reference.md](docs/reference.md) | every tool, parameter, status, CLI flag, environment variable and model |
242
277
  | [docs/how-it-works.md](docs/how-it-works.md) | the technical specification: one step end to end, guards, safety |
243
278
  | [docs/journeys.md](docs/journeys.md) | the journey-map tools |
244
- | [INSTALL-FOR-AGENTS.md](INSTALL-FOR-AGENTS.md) | agents installing jevway for a person |
279
+ | [SETUP.md](SETUP.md) | agents setting jevway up for a person, with a recorded smoke test |
245
280
  | [skill/jevway/SKILL.md](skill/jevway/SKILL.md) | the skill agents load: when and how to call jevway |
246
281
  | [docs/verification.md](docs/verification.md) | the test suites and their results |
247
282
  | [docs/prior-art.md](docs/prior-art.md) | other open-source Jev browser projects, and what was taken from them |
@@ -1,20 +1,34 @@
1
- # Installing jevway (for agents)
2
-
3
- You are an agent asked to install jevway for a person. jevway is an MCP server, `jevway-mcp`, plus a CLI, `jevway`.
4
- It hands browser goals to TypeSafe Jev. Both come from the npm package `jevway`.
5
-
6
- Read this whole file before you run anything. Run only these:
1
+ # Setting up jevway (for agents)
2
+
3
+ You are an agent asked to set up jevway for a person and prove it works. jevway is an MCP server, `jevway-mcp`, plus a
4
+ CLI, `jevway`, both from the npm package `jevway`. It hands browser goals to TypeSafe Jev and returns one checked
5
+ summary per goal.
6
+
7
+ Read this whole file before you run anything. You will:
8
+ 1. check the machine's prerequisites with the person and help install what's missing;
9
+ 2. get the API key in place (the person does that part);
10
+ 3. register the MCP server with the client you're running in;
11
+ 4. pick the browser with the person;
12
+ 5. install the skill;
13
+ 6. check the setup;
14
+ 7. run a recorded smoke test, from Google to Wikipedia;
15
+ 8. report back with the run page, its video and screenshots, as proof.
16
+
17
+ Run only these yourself:
18
+ - `node --version`, `ffmpeg -version`, and the person-approved install commands from step 1;
7
19
  - `npx -y jevway doctor --json` (or `--plan`, or `jevway doctor`);
8
20
  - `npx -y jevway setup --client <name> --print [--json]`, which prints how to register jevway with a client and
9
21
  changes nothing;
10
22
  - `npx -y jevway skill --install <skills folder>`;
11
23
  - `npm install -g jevway`;
12
- - the client's own `mcp add` command, or an edit to its MCP config file that the person approved.
24
+ - the client's own `mcp add` command, or an edit to its MCP config file that the person approved;
25
+ - the jevway tools themselves (or `npx -y jevway run …`) for the smoke test.
13
26
 
14
27
  Never run plain `jevway setup` yourself: it is the person's interactive setup, and without a terminal it exits 2 with
15
28
  `{ "ok": false, "error": { "code": "needs_tty", … } }`.
16
29
 
17
- Done means: `jevway doctor --json` prints `"ready": true` (exit 0), and the client lists the `jevway_*` tools.
30
+ Done means: `jevway doctor --json` prints `"ready": true`, the client lists the `jevway_*` tools, and the smoke test
31
+ ended `done` with a run page you showed the person.
18
32
 
19
33
  ## Rules
20
34
 
@@ -45,6 +59,19 @@ It needs no key to run and never prints one. It checks:
45
59
  - the browser source;
46
60
  - ffmpeg (optional: only for recordings).
47
61
 
62
+ `npx` itself needs Node, so check `node --version` first. For anything missing, propose the command that fits the
63
+ person's OS and package manager, and run it only after they say yes (or wait while they install it themselves, then
64
+ check again):
65
+
66
+ | Tool | Needed for | macOS (Homebrew) | Debian / Ubuntu | Fedora | Windows (winget) |
67
+ | --- | --- | --- | --- | --- | --- |
68
+ | Node.js 24+ | running jevway (required) | `brew install node` | Node 24 from NodeSource or nvm | `sudo dnf install nodejs` (check it's 24+) | `winget install OpenJS.NodeJS` |
69
+ | Chrome or Chromium | every run (required) | `brew install --cask google-chrome` | Google's `.deb`, or `sudo apt install chromium` | `sudo dnf install chromium` | `winget install Google.Chrome` |
70
+ | ffmpeg | the video in a recording (optional) | `brew install ffmpeg` | `sudo apt install ffmpeg` | `sudo dnf install ffmpeg-free` | `winget install Gyan.FFmpeg` |
71
+ | notify-send | the desktop notification when a person is needed (optional, Linux) | built in | `sudo apt install libnotify-bin` | `sudo dnf install libnotify` | not used |
72
+
73
+ A Chrome in an unusual place: set `CHROME_PATH` in `~/.config/jevway/env`.
74
+
48
75
  If the first `npx` start is slow, or a client times out starting the server, install once with `npm install -g jevway`.
49
76
  Then use `jevway-mcp` as the command instead of `npx -y -p jevway jevway-mcp`.
50
77
 
@@ -97,7 +124,8 @@ npx -y jevway setup --client cursor --print --json # { client, kind, command?, f
97
124
 
98
125
  ## 4. The browser
99
126
 
100
- `JEVWAY_BROWSER` (the server's env, or `--browser` for the CLI):
127
+ Ask the person which browser jevway should use, and set `JEVWAY_BROWSER` in the server's env (or `--browser` for
128
+ the CLI) when it isn't `auto`. For the smoke test a visible one is best, so they can pass a check if one appears.
101
129
 
102
130
  | Value | What it uses | What the person does |
103
131
  | --- | --- | --- |
@@ -117,11 +145,13 @@ npx -y jevway skill --install ~/.codex/skills # or ~/.claude/skills, ~/.cursor
117
145
  It prints `{ ok, status, path, files }`. A different `jevway` skill already there (or a symlink to one) is left alone
118
146
  with `"code": "skill_exists"` and exit 1; replace it with `--force` only if the person agrees.
119
147
 
120
- ## 6. Check the outcome
148
+ ## 6. Check the setup
121
149
 
122
- 1. `npx -y jevway doctor --json` prints `"ready": true` and exits 0 (every finding `ok` or `warn`).
123
- 2. The client lists the tools: `jevway_task`, `jevway_look`, `jevway_end` and `jevway_journey_start`, `_step`,
124
- `_autopilot`, `_annotate`, `_finish`.
150
+ 1. `npx -y jevway doctor --json` prints `"ready": true` and exits 0 (every finding `ok` or `warn`). For every finding
151
+ that isn't `ok`, tell the person the fix in one line and wait; re-run it after each fix (`--plan` lists the steps
152
+ left).
153
+ 2. The client lists the tools: `jevway_task`, `jevway_look`, `jevway_end`, the five `jevway_journey_*` tools and
154
+ `jevway_inbox_create` / `jevway_inbox_wait`.
125
155
  - In Claude Code: `/mcp`.
126
156
  - Elsewhere, the client's MCP list.
127
157
  - Or the MCP Inspector:
@@ -129,4 +159,54 @@ with `"code": "skill_exists"` and exit 1; replace it with `--force` only if the
129
159
  3. Optional, costs a fraction of a cent: `npx -y jevway doctor --live` makes one real Jev request and reports its
130
160
  latency.
131
161
 
132
- When a check fails, the doctor names the cause and the fix, and `--plan` lists the steps left. Re-run it after each fix.
162
+ ## 7. The smoke test
163
+
164
+ Call `jevway_task` with:
165
+
166
+ ```json
167
+ {
168
+ "url": "https://www.google.com",
169
+ "goal": "Search Google for the Rosetta Stone and open its English Wikipedia article",
170
+ "values": { "query": "Rosetta Stone Wikipedia" },
171
+ "done_when": "The English Wikipedia article about the Rosetta Stone is open",
172
+ "allowed_hosts": ["google.com", "*.google.com", "en.wikipedia.org"],
173
+ "record": true,
174
+ "detail": "full"
175
+ }
176
+ ```
177
+
178
+ What to expect, and what to do:
179
+ - **A cookie banner** is handled by jevway (it declines when it can).
180
+ - **A reCAPTCHA ("unusual traffic")** is common for automated browsers on Google. In a visible browser jevway brings
181
+ the tab to the front, sends the person a desktop notification and waits up to 2 minutes for them to pass it; it
182
+ never solves it. Tell the person it may happen before you start.
183
+ - **A result with `ask_user`** (a bot check, a sign-in, a missing value): put that question to the person with your
184
+ own ask tool, then do what its `then` says.
185
+ - **Google keeps blocking**: run the same goal from `https://duckduckgo.com` with
186
+ `allowed_hosts: ["duckduckgo.com", "en.wikipedia.org"]`. It usually finishes in 5 steps.
187
+ - **No MCP in your client**: run the same test with the CLI, which prints one JSON result on stdout:
188
+
189
+ ```bash
190
+ npx -y jevway run --browser launch --record --detail full --url https://www.google.com \
191
+ --goal "Search Google for the Rosetta Stone and open its English Wikipedia article" \
192
+ --values '{"query":"Rosetta Stone Wikipedia"}' \
193
+ --done-when "The English Wikipedia article about the Rosetta Stone is open" \
194
+ --allowed-hosts "google.com,*.google.com,en.wikipedia.org"
195
+ ```
196
+
197
+ End the session with `jevway_end` when you have what you need.
198
+
199
+ ## 8. Report back
200
+
201
+ Tell the person, in this shape:
202
+ - **Outcome:** `status` and `reason`, and the `story` as a numbered list.
203
+ - **Proof:** the run page `files.page` (open it for them: `open` on macOS, `xdg-open` on Linux, `start` on Windows),
204
+ the video `files.video` (present when ffmpeg is installed), the end screenshot `files.screenshot` (show it if you
205
+ can display images), and the run folder `files.run_dir` with its `manifest.json`.
206
+ - **Cost:** `jev.requests` and `jev.est_cost_usd` (an estimate; a typical smoke test is well under a cent).
207
+ - **Setup:** what you installed or changed, with the person's yes for each.
208
+ - **Anything that needed them**, and anything that didn't work, with the fix.
209
+
210
+ The run page opens with the goal and how it ended, then the video with every step on a timeline and a strip of
211
+ screenshots kept in sync. It is the proof that the whole chain works: the client, the MCP server, the key, the
212
+ browser and Jev.
@@ -0,0 +1,121 @@
1
+ const MIN_BOXES = 4;
2
+ const MAX_BOXES = 8;
3
+ // One character fits in a box this wide at any common font size; a field for a word is wider.
4
+ const ONE_CHARACTER_PX = 64;
5
+ const CODE_MODES = ['numeric', 'decimal', 'text', 'tel'];
6
+ const CODE_TYPES = ['tel', 'number'];
7
+ // Words a box's own label shares with its neighbours that name no field ("Digit 1 of 6").
8
+ const GENERIC_WORDS = /^(?:digit|character|char|box|number|input|field|code|otp|pin|textbox|spinbutton)s?$/i;
9
+ const FALLBACK_NAME = 'Verification code';
10
+ const holdsOneCharacter = (action) => {
11
+ const { box, rect } = action;
12
+ if (action.kind !== 'fill' || !box || !rect || action.hover)
13
+ return false;
14
+ if (box.max === 1)
15
+ return true;
16
+ return box.max < 0 && rect.w <= ONE_CHARACTER_PX && (CODE_MODES.includes(box.mode) || CODE_TYPES.includes(box.type) || box.otp);
17
+ };
18
+ const centreY = (box) => box.rect.y + box.rect.h / 2;
19
+ // One form, one container (every box in the same parent, or each wrapped once in the same grandparent), one row, read
20
+ // left to right, all about the same width.
21
+ const joins = (run, next) => {
22
+ const [first] = run;
23
+ const last = run.at(-1);
24
+ const widths = [...run, next].map((box) => box.rect.w);
25
+ return (next.box.form === first.box.form &&
26
+ [0, 1].some((level) => run.every((box) => box.box.within[level] === next.box.within[level])) &&
27
+ Math.abs(centreY(next) - centreY(first)) <= first.rect.h / 2 &&
28
+ next.rect.x > last.rect.x &&
29
+ Math.max(...widths) <= Math.min(...widths) * 1.5);
30
+ };
31
+ // Runs of one-character boxes that follow one another among the listed fields; another field between them ends a run.
32
+ const runsOf = (actions) => {
33
+ const runs = [];
34
+ let run = [];
35
+ for (const action of actions) {
36
+ if (action.kind !== 'fill')
37
+ continue;
38
+ if (holdsOneCharacter(action) && run.length && joins(run, action)) {
39
+ run.push(action);
40
+ continue;
41
+ }
42
+ if (run.length)
43
+ runs.push(run);
44
+ run = holdsOneCharacter(action) ? [action] : [];
45
+ }
46
+ if (run.length)
47
+ runs.push(run);
48
+ return runs.filter((boxes) => boxes.length >= MIN_BOXES && boxes.length <= MAX_BOXES);
49
+ };
50
+ // "Security code character 3" → "Security code character": a box's label without its place in the group.
51
+ const stemOf = (label) => label
52
+ .replace(/\d+/g, ' ')
53
+ .replace(/\bof\b/gi, ' ')
54
+ .replace(/^[^\p{L}]+|[^\p{L}]+$/gu, '')
55
+ .replace(/\s+/g, ' ');
56
+ const nameOf = (run) => {
57
+ if (run[0].box.name)
58
+ return run[0].box.name;
59
+ const stems = new Set(run.map((box) => stemOf(box.label)));
60
+ const [stem] = stems;
61
+ return stems.size === 1 && stem && !GENERIC_WORDS.test(stem) ? stem : FALLBACK_NAME;
62
+ };
63
+ const unionOf = (run) => {
64
+ const left = Math.min(...run.map((box) => box.rect.x));
65
+ const top = Math.min(...run.map((box) => box.rect.y));
66
+ const right = Math.max(...run.map((box) => box.rect.x + box.rect.w));
67
+ const bottom = Math.max(...run.map((box) => box.rect.y + box.rect.h));
68
+ return { x: left, y: top, w: right - left, h: bottom - top };
69
+ };
70
+ const withoutFacts = ({ box: _box, ...action }) => action;
71
+ /**
72
+ * A code split into one-character boxes (4–8 `maxlength=1` boxes, or narrow numeric/one-time-code ones with no
73
+ * `maxlength`, following one another in one form and container, left to right on one row) becomes one `fill` target on
74
+ * its first box: `boxes` lists every box, `value` joins their values, `rect` covers them all, and the label is the
75
+ * group's name with the count. The other boxes and their `Open …` clicks are not listed. The reader's facts are removed
76
+ * from every action, and element ids are renumbered when anything was grouped.
77
+ *
78
+ * @example
79
+ * groupCodeBoxes(snapshot.actions) // [{ kind: 'fill', node: 11, label: 'Verification code (6 boxes)', boxes: [11, …, 16], … }, …]
80
+ */
81
+ export const groupCodeBoxes = (actions) => {
82
+ const runs = runsOf(actions);
83
+ if (!runs.length)
84
+ return actions.some((action) => action.box) ? actions.map(withoutFacts) : actions;
85
+ const groupOf = new Map();
86
+ for (const run of runs) {
87
+ const group = {
88
+ first: run[0].node,
89
+ boxes: run.map((box) => box.node),
90
+ label: `${nameOf(run)} (${run.length} boxes)`,
91
+ value: run.map((box) => box.value ?? '').join(''),
92
+ rect: unionOf(run),
93
+ };
94
+ for (const node of group.boxes)
95
+ groupOf.set(node, group);
96
+ }
97
+ let next = 0;
98
+ return actions.flatMap((action) => {
99
+ const group = action.node === undefined ? undefined : groupOf.get(action.node);
100
+ if (group && action.node !== group.first)
101
+ return [];
102
+ const { label, value, boxes, rect } = group ?? {};
103
+ const listed = group ? { ...withoutFacts(action), label: action.kind === 'click' ? `Open ${label}` : label, value, boxes, rect } : withoutFacts(action);
104
+ return [action.node === undefined ? listed : { ...listed, id: `e${++next}` }];
105
+ });
106
+ };
107
+ /**
108
+ * The characters a code puts into `count` boxes, one each: the text as given, or without its spaces and hyphens when
109
+ * only that fits ("739 214" into 6 boxes). Undefined when neither fits.
110
+ *
111
+ * @example
112
+ * codeCharacters('739-214', 6) // ['7', '3', '9', '2', '1', '4']
113
+ */
114
+ export const codeCharacters = (text, count) => {
115
+ for (const form of [text, text.replace(/[\s-]/g, '')]) {
116
+ const characters = [...form];
117
+ if (characters.length === count)
118
+ return characters;
119
+ }
120
+ return undefined;
121
+ };
@@ -0,0 +1,103 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, relative, isAbsolute } from 'node:path';
4
+ const STAGING = new WeakMap();
5
+ /**
6
+ * Sets how the browser behind `cdp` downloads, once per connection: jevway's launched browser saves every download under
7
+ * its guid in a staging folder of its own; the person's Chrome (`auto`, a debugging URL) keeps its own behaviour and only
8
+ * reports downloads. A browser that refuses never fails a run: its downloads go unreported.
9
+ *
10
+ * @example
11
+ * await useDownloads(cdp, 'launch-headless'); // Browser.setDownloadBehavior { behavior: 'allowAndName', downloadPath: '/tmp/jevway-downloads-…' }
12
+ */
13
+ export const useDownloads = async (cdp, source) => {
14
+ if (source !== 'launch' && source !== 'launch-headless') {
15
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'default', eventsEnabled: true }).catch(() => undefined);
16
+ return;
17
+ }
18
+ const downloadPath = mkdtempSync(join(tmpdir(), 'jevway-downloads-'));
19
+ STAGING.set(cdp, downloadPath);
20
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath, eventsEnabled: true }).catch(() => undefined);
21
+ };
22
+ /** Removes the staging folder of a launched browser's connection, with whatever was never kept. */
23
+ export const dropStagedDownloads = (cdp) => {
24
+ const dir = STAGING.get(cdp);
25
+ if (dir)
26
+ rmSync(dir, { recursive: true, force: true });
27
+ STAGING.delete(cdp);
28
+ };
29
+ const inside = (dir, path) => {
30
+ if (!dir || !path)
31
+ return false;
32
+ const rel = relative(dir, path);
33
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
34
+ };
35
+ /**
36
+ * The downloads one tab started: `owns` says whether a frame id is the tab's (its page, one of its frames, a tab it
37
+ * opened). Every other download, in the person's other tabs too, is never recorded.
38
+ *
39
+ * @example
40
+ * const downloads = new TabDownloads(cdp, (frameId) => frameId === targetId);
41
+ * const finished = await downloads.settle({ waitMs: 30_000 });
42
+ */
43
+ export class TabDownloads {
44
+ #cdp;
45
+ #owns;
46
+ #all = new Map();
47
+ #untaken = [];
48
+ #changed = new Set();
49
+ constructor(cdp, owns) {
50
+ this.#cdp = cdp;
51
+ this.#owns = owns;
52
+ }
53
+ /** Starts listening; returns the unsubscribe. */
54
+ watch() {
55
+ return this.#cdp.onEvent((method, params, sessionId) => {
56
+ if (sessionId || !method.startsWith('Browser.download'))
57
+ return;
58
+ if (method === 'Browser.downloadWillBegin' && this.#owns(params.frameId)) {
59
+ this.#all.set(params.guid, { guid: params.guid, name: params.suggestedFilename, url: params.url, state: 'inProgress', bytes: 0, staged: false });
60
+ this.#untaken.push(params.guid);
61
+ }
62
+ const known = method === 'Browser.downloadProgress' && this.#all.get(params.guid);
63
+ if (!known)
64
+ return;
65
+ const filePath = params.filePath ?? known.filePath;
66
+ this.#all.set(params.guid, {
67
+ ...known,
68
+ state: params.state,
69
+ bytes: params.receivedBytes ?? known.bytes,
70
+ ...(filePath && { filePath }),
71
+ staged: inside(STAGING.get(this.#cdp), filePath),
72
+ });
73
+ for (const changed of this.#changed)
74
+ changed();
75
+ });
76
+ }
77
+ /**
78
+ * Takes the downloads begun since the last take, after waiting up to `waitMs` (or until `signal`) for those still
79
+ * running. Each comes back as it stands then: completed, canceled, or still in progress.
80
+ */
81
+ async settle({ waitMs, signal }) {
82
+ const taken = this.#untaken.splice(0);
83
+ const running = () => taken.some((guid) => this.#all.get(guid)?.state === 'inProgress');
84
+ if (taken.length && running() && !signal?.aborted) {
85
+ await new Promise((resolve) => {
86
+ const done = () => {
87
+ clearTimeout(timer);
88
+ signal?.removeEventListener('abort', done);
89
+ this.#changed.delete(check);
90
+ resolve();
91
+ };
92
+ const check = () => {
93
+ if (!running())
94
+ done();
95
+ };
96
+ const timer = setTimeout(done, waitMs);
97
+ signal?.addEventListener('abort', done, { once: true });
98
+ this.#changed.add(check);
99
+ });
100
+ }
101
+ return taken.map((guid) => ({ ...this.#all.get(guid) }));
102
+ }
103
+ }
@@ -1,5 +1,6 @@
1
1
  import { centerOf, contains } from '../jev/box.js';
2
2
  import { CdpError } from './cdp.js';
3
+ import { groupCodeBoxes } from './code-boxes.js';
3
4
  // jevway: every frame is read with the same reader (snapshot.js), each in an isolated world of its own, so ids, guards and
4
5
  // the input counter live where the frame's elements live. The main frame keeps the main world, exactly as before frames.
5
6
  /** Each frame owns a range of element ids this wide; the main frame is range 0, so its ids are what they always were. */
@@ -134,6 +135,7 @@ export class Frames {
134
135
  #sessionId;
135
136
  #counter;
136
137
  #touch = false;
138
+ #files = false;
137
139
  #mainFrameId = '';
138
140
  #viewport = { w: 0, h: 0 };
139
141
  /** Out-of-process frames attached so far, by target id (= frame id). */
@@ -164,6 +166,20 @@ export class Frames {
164
166
  for (const { sessionId } of this.#children.values())
165
167
  void this.#emulateTouch(sessionId);
166
168
  }
169
+ /**
170
+ * The caller declared files: frame reads list file inputs and drop zones, and out-of-process frames keep native file
171
+ * pickers closed too (the page's own session does that for same-process frames).
172
+ */
173
+ set files(enabled) {
174
+ if (enabled && !this.#files)
175
+ for (const { sessionId } of this.#children.values())
176
+ void this.#interceptFileChooser(sessionId);
177
+ this.#files = enabled;
178
+ }
179
+ /** Whether a protocol frame id is this tab's: its page, a frame read on it, or an out-of-process frame attached to it. */
180
+ holds(frameId) {
181
+ return Boolean(frameId) && (frameId === this.#mainFrameId || this.#known.has(frameId) || this.#children.has(frameId));
182
+ }
167
183
  /** The frame holding an element id: undefined for the main frame and for a frame no longer on the page. */
168
184
  ofNode(node) {
169
185
  const base = idBaseOf(node);
@@ -228,11 +244,11 @@ export class Frames {
228
244
  // The text walk keeps to the part of the frame the page shows, in the frame's own coordinates.
229
245
  const { offset, region } = placement;
230
246
  const view = { x: region.x - offset.x, y: region.y - offset.y, w: region.w, h: region.h };
231
- const response = await this.evaluate(frame, `window.__jevIdBase=${frame.base};window.__jevView=${JSON.stringify(view)};window.__jevWanted=${JSON.stringify(wanted)};${script}`, { timeout: FRAME_READ_MS });
247
+ const response = await this.evaluate(frame, `window.__jevIdBase=${frame.base};window.__jevView=${JSON.stringify(view)};window.__jevWanted=${JSON.stringify(wanted)};window.__jevFiles=${this.#files};${script}`, { timeout: FRAME_READ_MS });
232
248
  const state = response.exceptionDetails ? null : response.result?.value;
233
249
  if (!state)
234
250
  return null;
235
- const placed = placeFrameActions(state.actions, { w: state.w, h: state.h }, placement, frame.id);
251
+ const placed = placeFrameActions(groupCodeBoxes(state.actions), { w: state.w, h: state.h }, placement, frame.id);
236
252
  // On-screen controls must be on top in every document around the frame: a banner or sheet over the frame hides them.
237
253
  const shown = placed.filter((action) => contains(placement.region, centerOf(action.rect)));
238
254
  const onTop = await this.#hits(levels, shown.map((action) => centerOf(action.rect)));
@@ -404,6 +420,8 @@ export class Frames {
404
420
  await this.#send('Page.addScriptToEvaluateOnNewDocument', { source: this.#counter, worldName: WORLD }, sessionId);
405
421
  await this.#send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId).catch(() => undefined);
406
422
  await this.#emulateTouch(sessionId);
423
+ if (this.#files)
424
+ await this.#interceptFileChooser(sessionId);
407
425
  await this.#autoAttach(sessionId);
408
426
  }
409
427
  catch {
@@ -418,6 +436,9 @@ export class Frames {
418
436
  const enabled = this.#touch;
419
437
  return this.#send('Emulation.setTouchEmulationEnabled', { enabled, maxTouchPoints: enabled ? 5 : 1 }, sessionId).catch(() => undefined);
420
438
  }
439
+ #interceptFileChooser(sessionId) {
440
+ return this.#send('Page.setInterceptFileChooserDialog', { enabled: true }, sessionId).catch(() => undefined);
441
+ }
421
442
  #autoAttach(sessionId) {
422
443
  return this.#send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: 'iframe' }] }, sessionId);
423
444
  }
@@ -332,6 +332,34 @@
332
332
  if (['auto', 'scroll'].includes(getComputedStyle(p).overflowX) && p.scrollWidth > p.clientWidth) return row;
333
333
  return null;
334
334
  };
335
+ // jevway: a short text input may be one box of a code split into one-character boxes. The tab groups them from these
336
+ // facts (code-boxes.ts); only the group's name is read here, where the page is: an aria name or legend around the
337
+ // boxes, else a short line right before them. Container and form keys are unique within this read.
338
+ const containerKeys = new Map();
339
+ const keyOf = (n) => (n ? containerKeys.get(n) ?? containerKeys.set(n, containerKeys.size + 1).get(n) : 0);
340
+ const codeGroupName = (e) => {
341
+ for (let p = e.parentElement, up = 0; p && up < 3 && p !== document.body && p.tagName !== 'FORM'; p = p.parentElement, up++) {
342
+ const legend = p.tagName === 'FIELDSET' ? p.querySelector('legend') : null;
343
+ const own = p.getAttribute('aria-label') || (p.hasAttribute('aria-labelledby') ? name(p) : '') || legend?.innerText || '';
344
+ if (own.trim()) return cap(own.replace(/\s+/g, ' ').trim(), 80);
345
+ }
346
+ for (let p = e, up = 0; p && up < 3 && p !== document.body; p = p.parentElement, up++) {
347
+ const before = p.previousElementSibling;
348
+ if (!before || before.matches(interactive) || before.querySelector(interactive) || !visible(before)) continue;
349
+ const line = before.innerText.replace(/\s+/g, ' ').trim();
350
+ if (line) return line.length <= 80 ? line : '';
351
+ }
352
+ return '';
353
+ };
354
+ const codeBoxFacts = (e) => {
355
+ if (e.tagName !== 'INPUT') return null;
356
+ const max = e.maxLength;
357
+ if (!(max >= 1 && max <= 4) && !(max < 0 && e.getBoundingClientRect().width <= 64)) return null;
358
+ const parent = e.parentElement;
359
+ const within = [keyOf(parent), keyOf(parent?.parentElement)];
360
+ const facts = { max, mode: e.inputMode || '', otp: e.autocomplete === 'one-time-code', type: e.type, form: keyOf(e.form), within };
361
+ return { box: { ...facts, name: codeGroupName(e) } };
362
+ };
335
363
  for (const e of candidates) {
336
364
  const shown = eligible(e);
337
365
  const hover = !shown && revealedByHover(e);
@@ -402,7 +430,7 @@
402
430
  (rname === 'combobox' && (['INPUT', 'TEXTAREA'].includes(e.tagName) || e.isContentEditable)));
403
431
  const value =
404
432
  'value' in e ? String(e.value) : e.isContentEditable || rname === 'combobox' ? e.innerText.trim() : '';
405
- actions.push({ ...base, kind: editable ? 'fill' : 'click', value });
433
+ actions.push({ ...base, kind: editable ? 'fill' : 'click', value, ...(editable && !hover && codeBoxFacts(e)) });
406
434
  if (editable) actions.push({ ...base, kind: 'click', value, label: 'Open ' + base.label });
407
435
  }
408
436
  }
@@ -424,10 +452,12 @@
424
452
  }
425
453
  return null;
426
454
  };
455
+ const anchors = [];
427
456
  for (const input of fileInputs) {
428
457
  if (input.matches(':disabled') || input.closest('[aria-disabled="true"],[inert]')) continue;
429
458
  const anchor = standInFor(input);
430
459
  if (!anchor || outOfView(anchor.getBoundingClientRect())) continue;
460
+ anchors.push(anchor);
431
461
  const r = cache.clickRect(anchor),
432
462
  own = (anchor === input ? name(input) : name(input) || name(anchor)).replace(/\s+/g, ' ').trim();
433
463
  actions.push({
@@ -439,6 +469,57 @@
439
469
  rect: { x: r.x, y: r.y, w: r.width, h: r.height },
440
470
  });
441
471
  }
472
+ // jevway: a drop zone with no file input in it is listed only on clear markers: an ondrop attribute, a drop-zone
473
+ // name in its class, id or a data- attribute, or a short text asking for a file to be dropped ("Drag and drop",
474
+ // "Drop your CV here"; its nearest box of 40×40 or more). The innermost zone wins; one holding or inside a file field's stand-in
475
+ // is that field already. The file is dropped onto it.
476
+ const DROP_NAME = /drop-?zone|drop-?area|file-?drop|drop-?target/i;
477
+ const DROP_TEXT =
478
+ /\bdrag\s*(?:and|&|'?n'?)\s*drop\b|\bdrop\s+(?:your\s+|a\s+|an\s+|the\s+)?(?:files?|cv|resume|résumé|images?|photos?|documents?|attachments?)\b/i;
479
+ const named = (e) =>
480
+ e.hasAttribute('ondrop') ||
481
+ DROP_NAME.test(typeof e.className === 'string' ? e.className : '') ||
482
+ DROP_NAME.test(e.id) ||
483
+ [...e.attributes].some((a) => a.name.startsWith('data-') && DROP_NAME.test(a.name));
484
+ const big = (e) => {
485
+ const r = e.getBoundingClientRect();
486
+ return r.width >= 40 && r.height >= 40;
487
+ };
488
+ const zones = new Set(
489
+ [...document.querySelectorAll('[ondrop],[class*="drop" i],[id*="drop" i]')].filter(named),
490
+ );
491
+ const texts = document.createTreeWalker(document.body ?? document.documentElement, NodeFilter.SHOW_TEXT, {
492
+ acceptNode: (t) => (t.data.length <= 200 && DROP_TEXT.test(t.data) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP),
493
+ });
494
+ for (let t = texts.nextNode(), seen = 0; t && seen < 20; t = texts.nextNode(), seen++) {
495
+ let box = t.parentElement;
496
+ for (let up = 0; box && up < 4 && !big(box); up++) box = box.parentElement;
497
+ if (box && big(box)) zones.add(box);
498
+ }
499
+ const listed = [...zones].filter(
500
+ (zone) =>
501
+ zone !== document.body &&
502
+ zone !== document.documentElement &&
503
+ visible(zone) &&
504
+ big(zone) &&
505
+ !outOfView(zone.getBoundingClientRect()) &&
506
+ !zone.querySelector('input[type=file]') &&
507
+ !anchors.some((anchor) => anchor.contains(zone) || zone.contains(anchor)),
508
+ );
509
+ for (const zone of listed) {
510
+ if (listed.some((other) => other !== zone && zone.contains(other))) continue;
511
+ const r = cache.clickRect(zone),
512
+ text = (zone.innerText || '').replace(/\s+/g, ' ').trim();
513
+ actions.push({
514
+ node: identity(zone),
515
+ role: 'file',
516
+ kind: 'upload',
517
+ drop: true,
518
+ label: cap(name(zone).replace(/\s+/g, ' ').trim() || text || 'drop zone', 120) + ' (drop zone)',
519
+ value: '',
520
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
521
+ });
522
+ }
442
523
  }
443
524
  // jevway: controls sharing a role and label (a product grid's "Add to cart" buttons) are told apart by the card or
444
525
  // row each sits in, as a person reads the page: "Add to cart — Sauce Labs Bike Light". Only colliding labels change;