@keepsnappy/agent 0.1.0-alpha.22 → 0.1.0-alpha.24
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 +97 -10
- package/dist/appium-driver.js +189 -26
- package/dist/keepsnappy.js +16803 -12125
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,9 @@ keepsnappy measure --repeat 10 \
|
|
|
16
16
|
keepsnappy extract runs/s1 --ri-capture observations.ndjson # re-derive metrics.csv (adds ri.* / mem.*)
|
|
17
17
|
keepsnappy aa runs/s1 [--reps 300 --boot 400] # noise floor / MDE per metric
|
|
18
18
|
keepsnappy doctor # xcrun, simctl, devicectl, maestro, appium, node, booted sims, devices
|
|
19
|
+
keepsnappy doctor --group setup [--json] [--app SLUG]... [--preflight | --step ID] # is this machine set up to watch, notify and investigate (see Setup below)
|
|
20
|
+
keepsnappy init [--yes] # fixes what `doctor --group setup` finds missing
|
|
21
|
+
keepsnappy init --detect [--json] # the repository folders found per app, and how each matched; writes nothing
|
|
19
22
|
```
|
|
20
23
|
|
|
21
24
|
- `--arms`: `NAME=<.app/.ipa path>` (installed with `simctl install` / `devicectl device install app`, only when the arm changes) or `NAME=<bundle id>` (already installed). Without `--arms` there is one arm `A`: plain repetition, usable for `keepsnappy aa`.
|
|
@@ -149,10 +152,7 @@ result, `3` with `--fail-on-worse` when the verdict computed on that run set is
|
|
|
149
152
|
verdict it checks nothing and says so, exit `0`), `2` for configuration, `1` when the API fails or nothing could be
|
|
150
153
|
judged ("Blocked": no iteration sent any data).
|
|
151
154
|
|
|
152
|
-
Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (
|
|
153
|
-
no data ("No result") at once. Results are posted after the verdict is computed, by the operator's recompute job (decision 0031), in
|
|
154
|
-
two lines and one button (docs/mockups/2026-10-26-slack, present `resultSlackPayload`). `$GITHUB_STEP_SUMMARY` gets the same two
|
|
155
|
-
lines: "No result", the computed result, or "Not judged yet · <branch> / Waiting for the judgement" with the run set's page. A Slack failure is printed and never changes the exit code.
|
|
155
|
+
Slack: the CLI posts nothing to Slack, whatever webhook is set (`KEEPSNAPPY_SLACK_WEBHOOK` is no longer read). The operator's notify job (decision 0031, `docs/operations/notify.md`) posts each journey's result once its verdict is computed, in two lines and one button (docs/mockups/2026-10-26-slack, present `resultSlackPayload`), and a failed session once: fewer valid runs than a session needs, per journey, with each run's stored reason, whether or not `farm close` ran. `farm close` prints a measurement that sent no data as "Blocked" (exit 1) with the run set's link. `$GITHUB_STEP_SUMMARY` gets the same two lines: "No result", the computed result, or "Not judged yet · <branch> / Waiting for the judgement" with the run set's page.
|
|
156
156
|
|
|
157
157
|
Declared steps on the farm: `maestro/keepsnappy/step.yaml` / `step.js` / `send-markers.js` (copy them into the suite).
|
|
158
158
|
With a token set, `step.js` only records the marker; `send-markers.js`, run in `end-run.yaml` after each iteration,
|
|
@@ -216,33 +216,120 @@ export KEEPSNAPPY_KEY=pl_rk_…
|
|
|
216
216
|
keepsnappy investigate f_0123456789abcdef --repo ~/apps/my-app # stores the hypotheses on the finding
|
|
217
217
|
keepsnappy investigate f_0123456789abcdef --dry-run # everything except the last step; prints what would be stored
|
|
218
218
|
keepsnappy investigate f_0123456789abcdef --agent codex --max-minutes 10 --json
|
|
219
|
+
keepsnappy investigate f_0123456789abcdef --claude-path ~/.local/bin/claude --save # claude is not on PATH: say where; --save remembers it
|
|
219
220
|
```
|
|
220
221
|
|
|
221
222
|
What it does, in order:
|
|
222
223
|
1. Reads the investigation package with the same client as the MCP tool `get_investigation` (stored results only).
|
|
223
224
|
2. Checks that the commit ids in the package are 7–40 lowercase hex characters (anything else is refused before git is called), then creates a separate worktree of your repo (`git worktree add --detach`, in the system temp directory) at the commit the finding was measured at: the PR's commit, or main's commit for a main finding. Every git call the CLI makes on your repo runs with `-c core.hooksPath=/dev/null`, so the repo's hooks (a `post-checkout` that installs or generates things) never run for a PR's commit. Your working copy, branch and uncommitted files are never touched. The worktree is removed at the end, also after a failure, a timeout or Ctrl-C. The commit must be in your clone (`git fetch` first when it says it is not).
|
|
224
225
|
3. Computes the range itself, because the agent has no shell: `git log` and `git diff` (fixed arguments, `--no-ext-diff --no-textconv`, output capped: log 20 KB, stat 20 KB, diff 100 KB, with a note when cut) from main's commit to the measured commit, put in the prompt after the package. Without a main commit in the package, or with one your clone does not have, the prompt says so.
|
|
225
|
-
3b. **Checks for a false alarm first.** The area is the files the package names for the finding (the components' files, and the files in the place ids' screen, parent and target), kept only if they are plain repo-relative paths that exist at the measured commit. The CLI computes, with fixed arguments, hooks off, literal pathspecs after `--`, from the two measured trees (`git diff <main commit> <measured commit>`, not the PR's range since the fork: main may have changed the area after it, and then the PR builds the old code): a symmetric log (`<` a commit only main has, `>` one only the PR has), the stat and a capped diff (40 KB) of those files, and says `AREA DIFF EMPTY: yes | no | unknown`. It also reads the stored facts through the client the tools use (`main_sessions`, `main_vs_previous`, `place_trend`, and the package's noisy marks, threshold, measured-differently and setup notes, at most 12 KB) and puts them in the prompt as the false-alarm facts, so claude and codex see the same ones. If the area diff is empty
|
|
226
|
-
4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-
|
|
227
|
-
- `claude -p --output-format json --max-turns N --restricted --permission-mode dontAsk --permission-prompts none --tools Read,Grep,Glob --allowedTools <Read,Grep,Glob + the KeepSnappy read tools by name> --disallowedTools Bash,PowerShell,Edit,Write,… --disable-slash-commands --mcp-config <private file> --strict-mcp-config`. Which flag closes which hole: `--restricted` ignores the user's, the project's (a PR's committed `.claude/settings.json`, with its hooks and allow rules) and the local settings files and removes Bash; `--permission-mode dontAsk` and `--permission-prompts none` deny whatever is not allowed and ask nobody; `--tools` and `--allowedTools` give it Read, Grep, Glob and the read-only KeepSnappy tools only (`get_investigation`, `get_finding`, `list_findings`, `list_problems`, `compare_runs`, `window_history`, `commits_between`, `session_places`, `place_trend`, `main_sessions`, `main_changes`, `main_vs_previous`, `pr_status`, `explain_layers`; never `submit_hypotheses`, so a dry run cannot store, and never `recompute_with_new_rules`; no wildcard); `--strict-mcp-config` keeps your other MCP servers out; `--disable-slash-commands` keeps the repo's and your skills and commands out. The KeepSnappy key is in a mode 0600 file that is deleted afterwards, not in the arguments or the agent's environment. It relies on the
|
|
228
|
-
- `codex exec --ignore-user-config --ignore-rules --ephemeral -c default_permissions="ks" -c 'permissions.ks.filesystem={":minimal"="read","<worktree>"="read","/private/tmp"="none","/tmp"="none"}' -c approval_policy="never" -c project_doc_max_bytes=0 --color never -o <file> -`. **Codex's read scope is the worktree and the system minimum, with `/tmp` and `/private/tmp` denied by name (the minimum includes `/tmp`, which holds other tools' output); its shell can write nothing and reach no network, and git cannot run in it, so the prompt carries the log, stat and diff.** The permission profile replaces `--sandbox read-only`, which reads every file you can (`~/.zshenv`, `~/.ssh`): with it, a PR's text could make codex quote a secret into a hypothesis that is then stored on the server. Checked with `codex sandbox` and the same `-c` values (no model; the test `investigate.test.ts` runs it when `codex` is installed): the worktree reads (also under `/tmp`); a sibling file, `/tmp` and the home directory do not. **Not checked: that `codex exec` applies the profile the same way in a live run** (that needs a model). `project_doc_max_bytes=0` keeps the repo's `AGENTS.md` out of the prompt (checked with `codex debug prompt-input`). Whether `codex exec --ignore-user-config` still loads a repo's `.codex/config.toml` or hooks is not checked against a live run either; trust for a project lives in the user config it ignores. `--ignore-user-config` keeps your configured MCP servers and approval reviewer out.
|
|
226
|
+
3b. **Checks for a false alarm first.** The area is the files the package names for the finding (the components' files, and the files in the place ids' screen, parent and target), kept only if they are plain repo-relative paths that exist at the measured commit. The CLI computes, with fixed arguments, hooks off, literal pathspecs after `--`, from the two measured trees (`git diff <main commit> <measured commit>`, not the PR's range since the fork: main may have changed the area after it, and then the PR builds the old code): a symmetric log (`<` a commit only main has, `>` one only the PR has), the stat and a capped diff (40 KB) of those files, and says `AREA DIFF EMPTY: yes | no | unknown`. It also reads the stored facts through the client the tools use (`main_sessions`, `main_vs_previous`, `place_trend`, and the package's noisy marks, threshold, measured-differently and setup notes, at most 12 KB) and puts them in the prompt as the false-alarm facts, so claude and codex see the same ones. If the area diff is empty and no cause is found, the answer is one `kind: no-area-change` ("likely false alarm") hypothesis with `checked`; with a cause, the cause carries the fact as one short evidence line, and the schema refuses a cause and a `no-area-change` in one answer (the repair turn fixes it). The CLI leaves out a false alarm its own check does not support (the area changed, could not be checked, another range or file) with its reason; when nothing is left it submits nothing. Rows stored by an earlier prompt that hold both are listed by `keepsnappy hypotheses` as the cause with the false alarm folded in as one line. The server cannot check this: a `no-area-change` from the skill path (or any key holder) is stored on the agent's word. The last line of the log is `KEEPSNAPPY-STATUS <word> [n]`, which `keepsnappy watch` reads for its second notification.
|
|
227
|
+
4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-v7`, the same rules as the skill) and the package as the answer layout prints it. The prompt goes on stdin, so a large package does not hit the argument limit. Nothing the user or the repo configures can widen what the agent may do:
|
|
228
|
+
- `claude -p --output-format stream-json --verbose --max-turns N --restricted --permission-mode dontAsk --permission-prompts none --tools Read,Grep,Glob --allowedTools <Read,Grep,Glob + the KeepSnappy read tools by name> --disallowedTools Bash,PowerShell,Edit,Write,… --disable-slash-commands --mcp-config <private file> --strict-mcp-config`. Which flag closes which hole: `--restricted` ignores the user's, the project's (a PR's committed `.claude/settings.json`, with its hooks and allow rules) and the local settings files and removes Bash; `--permission-mode dontAsk` and `--permission-prompts none` deny whatever is not allowed and ask nobody; `--tools` and `--allowedTools` give it Read, Grep, Glob and the read-only KeepSnappy tools only (`get_investigation`, `get_finding`, `list_findings`, `list_problems`, `compare_runs`, `window_history`, `commits_between`, `session_places`, `place_trend`, `main_sessions`, `main_changes`, `main_vs_previous`, `pr_status`, `explain_layers`; never `submit_hypotheses`, so a dry run cannot store, and never `recompute_with_new_rules`; no wildcard); `--strict-mcp-config` keeps your other MCP servers out; `--disable-slash-commands` keeps the repo's and your skills and commands out. The KeepSnappy key is in a mode 0600 file that is deleted afterwards, not in the arguments or the agent's environment. It relies on the `result`, `is_error` and `subtype` fields of the last `result` event of the stream (the stream is read line by line; the events before it are where a run that never ends has its usage). All these flags are in `claude --help` of 2.1.308 (`--max-turns` is not listed there; the prompt's own tool-call budget does not depend on it). The repo's CLAUDE.md can still be read as text by the agent; it can run and write nothing.
|
|
229
|
+
- `codex exec --json [-m <codexModel>] --ignore-user-config --ignore-rules --ephemeral -c default_permissions="ks" -c 'permissions.ks.filesystem={":minimal"="read","<worktree>"="read","/private/tmp"="none","/tmp"="none"}' -c approval_policy="never" -c project_doc_max_bytes=0 --color never -o <file> -`. **Codex's read scope is the worktree and the system minimum, with `/tmp` and `/private/tmp` denied by name (the minimum includes `/tmp`, which holds other tools' output); its shell can write nothing and reach no network, and git cannot run in it, so the prompt carries the log, stat and diff.** The permission profile replaces `--sandbox read-only`, which reads every file you can (`~/.zshenv`, `~/.ssh`): with it, a PR's text could make codex quote a secret into a hypothesis that is then stored on the server. Checked with `codex sandbox` and the same `-c` values (no model; the test `investigate.test.ts` runs it when `codex` is installed): the worktree reads (also under `/tmp`); a sibling file, `/tmp` and the home directory do not. **Not checked: that `codex exec` applies the profile the same way in a live run** (that needs a model). `project_doc_max_bytes=0` keeps the repo's `AGENTS.md` out of the prompt (checked with `codex debug prompt-input`). Whether `codex exec --ignore-user-config` still loads a repo's `.codex/config.toml` or hooks is not checked against a live run either; trust for a project lives in the user config it ignores. `--ignore-user-config` keeps your configured MCP servers and approval reviewer out.
|
|
229
230
|
5. Budget: `--max-turns` (default 26: 25 tool calls and the answer) and `--max-minutes` (default 15). At the time limit the agent and every process it started are killed, and it says `no result within N min`. More than 5 MB of output stops it too.
|
|
230
|
-
6. Validates the answer with the one hypothesis schema (`@keepsnappy/dashboard-contract/hypotheses`), after filling `runner: cli`, the agent (the model name claude reports, else `claude` / `codex`), `prompt_version` and the package's `package_digest`. An invalid answer is shown with the schema's paths and is not submitted. An answer of no hypothesis stores nothing.
|
|
231
|
-
7. Submits with the same client as the MCP tool `submit_hypotheses` and prints `N hypotheses, open`, then each one: component, files, expected change. `--json` prints one document `{ finding_id, submission, stored }` (`stored` is null on a dry run). Progress and errors go to stderr.
|
|
231
|
+
6. Validates the answer with the one hypothesis schema (`@keepsnappy/dashboard-contract/hypotheses`), after filling `runner: cli`, the agent (the model name claude reports, else `claude` / `codex`), `prompt_version` and the package's `package_digest`. An invalid answer is shown with the schema's paths and is not submitted; before that the agent gets **one repair turn** (a second, short run: at most 2 turns and 5 minutes, same read-only tools) in which it is sent the exact errors, the limits and its refused answer, and the corrected answer goes through the same schema. The tokens of both runs are in the usage. Still refused, not JSON, or no answer: the run ends as refused (`outcome: invalid`, `KEEPSNAPPY-STATUS schema`). The refused answers (and an answer that is no JSON) are kept as `<output.dir>/investigate/<finding-id>-<time>.json` (default `keepsnappy-out`, mode 0600, the agent's text and the errors only; never uploaded); the path is in the log and in `--json` as `rejected_answer`. An answer of no hypothesis stores nothing.
|
|
232
|
+
7. Submits with the same client as the MCP tool `submit_hypotheses` and prints `N hypotheses, open`, then each one: component, files, expected change. `--json` prints one document `{ finding_id, outcome, submission, stored, usage }` (`stored` is null on a dry run). Progress and errors go to stderr.
|
|
233
|
+
8. **Records what the run used**, for every end of a run that started the agent (see "What a run used" below), and ends with one line: `used 68k tokens (~$0.07, agent reported ~$0.07)`.
|
|
234
|
+
|
|
235
|
+
### What a run used
|
|
236
|
+
|
|
237
|
+
Every run that started the agent is recorded in KeepSnappy, whatever its end:
|
|
238
|
+
|
|
239
|
+
| Outcome | When |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `submitted` | hypotheses stored (the run is linked to their submission) |
|
|
242
|
+
| `refused` | the CLI's false-alarm check left nothing to submit |
|
|
243
|
+
| `invalid` | the agent's answer did not match the hypothesis schema |
|
|
244
|
+
| `no-result` | no JSON object, or no hypothesis |
|
|
245
|
+
| `timeout` | the agent was stopped at `--max-minutes` |
|
|
246
|
+
| `stopped` | you stopped it with Ctrl-C (the post is cut off after 3 s, so the prompt comes back) |
|
|
247
|
+
| `error` | the agent failed or ran out of turns, a line of its output passed 16 MB, or the API refused the hypotheses |
|
|
248
|
+
|
|
249
|
+
Not recorded: `--dry-run` (shown, not stored) and a command that stopped before the agent started (no repo, no commit, agent not installed): no tokens were spent. A usage that cannot be stored is tried twice (10 s each, the same run id, which the API replays), then said on stderr; it does not fail the investigation.
|
|
250
|
+
|
|
251
|
+
What is recorded is what the agent printed, and `null` where it printed nothing: never 0, never a guess.
|
|
252
|
+
|
|
253
|
+
| Agent | Source | Input | Cache read | Cache write | Output | Model | Turns, time, cost |
|
|
254
|
+
|---|---|---|---|---|---|---|---|
|
|
255
|
+
| claude | the `result` event of `--output-format stream-json`: `modelUsage` (every model call of the run, subagents included), else `usage` (the main loop) | `inputTokens` | `cacheReadInputTokens` | `cacheCreationInputTokens`, split by lifetime from `usage.cache_creation` (`ephemeral_5m_input_tokens`, `ephemeral_1h_input_tokens`) | `outputTokens` | the one model in `modelUsage`; several: null, and `by_model` has the tokens of each | `num_turns`, `duration_ms`, `total_cost_usd` (claude's own figure) |
|
|
256
|
+
| codex | the `turn.completed` event of `codex exec --json` | `input_tokens` − `cached_input_tokens` | `cached_input_tokens` | `cache_write_input_tokens` if printed (no lifetime) | `output_tokens` (OpenAI counts the reasoning tokens inside it) | not printed: the one you pin with `--codex-model NAME` (`investigate.codexModel`; passes `codex -m NAME`), else null | not printed: null |
|
|
257
|
+
|
|
258
|
+
The shapes are read from what the vendors document and from codex's source, and checked with fixtures that model them; **not checked against a live `claude` or `codex` run** (that needs a model). The output of both is read line by line and not kept (megabytes of tool output are neither held in memory nor a reason to stop the run); a single line over 16 MB, or 512 MB in all, stops it as `error`.
|
|
259
|
+
|
|
260
|
+
**A run that never ends.** claude prints its `result` last. When it never comes (a timeout, a Ctrl-C), the usage is what streamed: the input side (input, cache read, cache write with its split) of each assistant message, once per message id. Its output count is only a placeholder until the message ends, so output is `null` and the estimate is `null`, but the input-side tokens are known. A crashed claude (`is_error` with every count 0: the Agent SDK says the cost fields may be zeroed) records `null`, not zeros. codex prints `turn.completed` only when a turn ends: a stop mid-turn has no usage (documented, not fixable).
|
|
261
|
+
|
|
262
|
+
**The estimate** is tokens × the list price of the model on an API key (`packages/dashboard-contract/src/model-prices.ts`, the one table the API and this CLI share, each entry with its official page and date), each kind at its own rate, and it prices the run as it ran: a `claude login` run writes the 1-hour cache (the 1-hour price), an API key defaults to the 5-minute cache (the 5-minute price); the split in claude's own report decides. With no split the cache-write part, and so the estimate, is `null`. Several models are priced one by one. A model priced by prompt size (Haiku 5.5, gpt-5.6-\*, gpt-5.5, gpt-5.4) is priced at its lower tier when all the prompt tokens of the whole run are within the limit (then no request was bigger), else `null`. The estimate is `null` too for an unknown model, a model not named (codex without `--codex-model`), or a kind of token not printed. It is not what your subscription costs and not a bill. `reported_cost_usd` is claude's own figure; it is shown beside the estimate, never in place of it.
|
|
263
|
+
|
|
264
|
+
`--json` has the usage as one object:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
"usage": {
|
|
268
|
+
"run_id": "inv-run-f_0123456789abcdef-1793303393464-a1b2c3", "outcome": "submitted", "agent": "claude", "model": "claude-sonnet-5-5", "by_model": null,
|
|
269
|
+
"input_tokens": 1200, "output_tokens": 3400, "cache_read_tokens": 56000, "cache_creation_tokens": 7800, "cache_creation_5m_tokens": 3000, "cache_creation_1h_tokens": 4800,
|
|
270
|
+
"total_tokens": 68400, "turns": 9, "duration_ms": 84000, "reported_cost_usd": 0.0687, "estimated_cost_usd": 0.0687, "price_table_version": "2026-10-29", "stored": true
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`stored` is false on `--dry-run` and when the API did not take it. On a run that ends without hypotheses `--json` prints the same document with `"submission": null`. The end line is `used 68k tokens (~$0.07, agent reported ~$0.07)`: the estimate, then the agent's own figure; either is left out when it is not known.
|
|
275
|
+
|
|
276
|
+
**For the Mac app.** The app reads `keepsnappy watch --json`, not this command's `--json`. So the log of an investigation carries the same object on one line, just before the status line: `KEEPSNAPPY-USAGE {…}`. `watch` reads the last such line when the investigation ends (only an object with exactly these keys and bounded values; anything else is no usage) and adds it to the `ended` event as `usage` (left out when the log has none). Decoding it in the app is not done yet; the key is additive, so the app as it is ignores it.
|
|
277
|
+
|
|
278
|
+
`keepsnappy hypotheses <finding-id> [--json]` lists what is stored about a finding: its hypotheses and the runs that made them (or did not), each with its tokens and cost. The runs ride in the same answer as the hypotheses (`GET /v1/findings/:id/hypotheses` adds `runs`, `usage` and `usage_text`), so `--json` is still the API's answer as it came, and a server that does not send them yet prints no runs. `usage_text` says how many runs a sum covers: `3 runs · 41k tokens for 2 of 3 runs · ~$0.06 for 1 of 3 runs`.
|
|
232
279
|
|
|
233
280
|
Setup, once. The repo comes from `--repo`, else from the app → repo map in `keepsnappy.yml` (the file `keepsnappy run` reads: the current directory, or `--config`). The app is the one the key reads. Nothing else in the file changes:
|
|
234
281
|
|
|
235
282
|
```yaml
|
|
236
283
|
investigate:
|
|
237
284
|
agent: claude # claude | codex (default claude); --agent wins
|
|
285
|
+
claudePath: ~/.local/bin/claude # the claude executable (default: claude on PATH); --claude-path wins
|
|
286
|
+
codexPath: /opt/codex/bin/codex # the codex executable (default: codex on PATH); --codex-path wins
|
|
238
287
|
max_turns: 26 # --max-turns wins
|
|
239
288
|
max_minutes: 15 # --max-minutes wins
|
|
240
289
|
repos: # app → local clone; ~ is your home, a relative path is relative to where you run the command
|
|
241
290
|
tapin: ~/apps/gigaverse-mobile
|
|
242
291
|
```
|
|
243
292
|
|
|
293
|
+
Where the agent is: `--agent claude|codex` picks it (default claude), then `--claude-path FILE` / `--codex-path FILE` (keys `investigate.claudePath` / `investigate.codexPath`) say which executable runs; without a path the name is looked up on `PATH`, as before. Order: flag, then `keepsnappy.yml` (or `--config FILE`), then your own config file, then `PATH`. `~/` is your home. Before anything starts (before the worktree, before the API is asked) the path of the agent in use must exist, be a regular file (symlinks are followed) and be executable by you; if not, the command stops with `--claude-path /x/claude: not found` (or `not a regular file`, `not executable`), or `investigate.claudePath /x/claude: not found (in <file>)` when the path came from a file, and exits 2. The path of the agent that is not in use is not looked at. The Mac app sets these from its settings.
|
|
294
|
+
|
|
295
|
+
Which account the agent runs on: `--claude-config-dir DIR` / `--codex-home DIR` (keys `investigate.claudeConfigDir` / `investigate.codexHome`, same order as the path: flag, `keepsnappy.yml` or `--config`, your config file) start the agent with `CLAUDE_CONFIG_DIR` / `CODEX_HOME` set to that folder, so an investigation uses the login kept there instead of the default one. The folder must exist (`--claude-config-dir /x: not found`, or `investigate.claudeConfigDir /x: not found (in <file>)`, exit 2); `~/` is your home; only the agent in use is looked at. `--preflight` asks `--version` the same way, `keepsnappy doctor` shows the folder in its `agent` and `agent-login` steps and asks `claude auth status` / `codex login status` with it set, and `--save` keeps it. Not set: nothing is added to the agent's environment, so a wrapper script used as `claudePath` that sets `CLAUDE_CONFIG_DIR` itself keeps working. `keepsnappy watch` forwards both flags; its `--auto-on-worse` runs `keepsnappy investigate --agent claude`, which reads `claudePath` and `claudeConfigDir` from the forwarded `--config` and from your config file.
|
|
296
|
+
|
|
297
|
+
Your own config file is `$XDG_CONFIG_HOME/keepsnappy/config.yml`, else `~/.config/keepsnappy/config.yml` (created on first write: directory 0700, file 0600). It has the same `investigate:` section as `keepsnappy.yml`; `keepsnappy investigate` and `keepsnappy watch` read it, a project's `keepsnappy.yml` (or `--config FILE`) wins over it key by key:
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
keepsnappy config set investigate.agent codex
|
|
301
|
+
keepsnappy config set investigate.claudePath ~/.local/bin/claude # checked on set: exists, a regular file, executable; stored as typed
|
|
302
|
+
keepsnappy config get investigate.claudePath
|
|
303
|
+
keepsnappy config unset investigate.repos.tapin # removes the key, nothing else in the file changes; exit 1 when it is not set
|
|
304
|
+
keepsnappy config list # the three keys that are set, and the file
|
|
305
|
+
keepsnappy config set investigate.codexPath /opt/codex/bin/codex --config ./my.yml # --config FILE: another file instead of your own
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`set` and `unset` accept only `investigate.agent`, `investigate.claudePath`, `investigate.codexPath`, `investigate.claudeConfigDir`, `investigate.codexHome` (an account folder; it must exist; stored absolute, `~/` stays) and `investigate.repos.<app>` (the folder of an app's git repository; it must exist; anything else is refused with the list) and edit the file line by line, so comments, other keys and other sections stay as they are (a key written in quotes, `"tapin":`, is the same key as `tapin:` and keeps its quotes; a `repos` map left without an entry is removed with `unset`); a file that is not valid YAML, or whose `investigate:` is written inline (`investigate: {}`), is refused and left untouched. `keepsnappy investigate --save` does the same `set` for the agent and the path that run used (only those; the path only when one was given by a flag or a file, not when `PATH` found the agent).
|
|
309
|
+
|
|
244
310
|
Exit codes: 0 stored (or a dry run, or no hypothesis), 1 no usable answer (timeout, agent error, invalid output), 2 wrong flags or settings, 130 Ctrl-C. Needs a key with read scope, like the MCP tools.
|
|
245
311
|
|
|
312
|
+
## Setup: `doctor --group setup`, `init`, `investigate --preflight`
|
|
313
|
+
|
|
314
|
+
`keepsnappy doctor --group setup` says whether this machine is ready to watch, notify and investigate; the Mac app's first-run window is a view of it. It calls no model and writes nothing. Steps, in order (a step is `ok`, `missing` or `broken`):
|
|
315
|
+
|
|
316
|
+
| id | checks |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `account` | a key is set (`KEEPSNAPPY_KEY` or `--key-env`) and the API answers; the detail is `connected: <org> · <app>` |
|
|
319
|
+
| `projects` | the apps in `watch.apps` (the key's app when none are named); each must be the key's app |
|
|
320
|
+
| `repo:<app>` | a folder is mapped (`investigate.repos.<app>`) and is a git repository. A remote must match the project's repository URL (`repo_url` on the events page; ssh and https forms, with or without `.git`, count as the same); with no URL set, or an API that does not send it yet, the step is `ok` with "repo URL unknown: remote not checked" |
|
|
321
|
+
| `agent` | `claude` or `codex` is chosen, its executable is found (the configured path, else `PATH`; when not on `PATH` the detail names where it was found) and `--version` runs |
|
|
322
|
+
| `agent-login` | the agent's own login status (`claude auth status`, `codex login status`; no model call); an agent without one is `ok` with a note |
|
|
323
|
+
| `notifications` | a notifier is present (alerter, terminal-notifier, or osascript on macOS) |
|
|
324
|
+
| `events` | the events read works; the count of the last 7 days |
|
|
325
|
+
| `preflight` | `keepsnappy investigate <newest finding> --preflight` (below). It checks a commit out, so `doctor` runs it only for `--preflight` or `--step preflight`; otherwise it is `missing` with the hint "run the check" |
|
|
326
|
+
|
|
327
|
+
`--json` prints `{"version":1,"steps":[{"id","group","state","detail","fix":{"hint","command"?}}]}`: both groups unless `--group setup|measure` is given (the measurement tools are the group `measure`, the same checks as plain `keepsnappy doctor`, which prints them as before and never fails). `fix.hint` is empty when the step is `ok`. Exit code 1 unless `account`, `projects` and `events` are `ok`. `--step ID` prints that one step (run, if it is the preflight) and exits 1 unless it is `ok`. `--app SLUG` (repeatable) makes `projects`, `repo:<app>` and the preflight judge only those apps, whatever `watch.apps` says (an app the key is not for is `broken`); `projects` shows the project's display name when the API sends one. `events` counts the results of the last 7 days the way `keepsnappy watch --history-days` lists them (same pages, same `watch.apps` / `watch.branch` / `watch.mine` filter).
|
|
328
|
+
|
|
329
|
+
`keepsnappy init [--yes] [--app SLUG] [--repo app=path ...] [--agent claude|codex] [--claude-path FILE] [--codex-path FILE] [--config FILE]` shows those steps, then asks about the ones that are `missing` or `broken`: the folder of each app (found by scanning `~/apps`, `~/code`, `~/src`, `~/Developer`, `~/Projects` three levels deep and `~` two levels, for 5 s at most, skipping hidden folders, `node_modules` and, in `~`, the folders macOS asks permission for), the agent, and the path of the agent (the login shell's `PATH`, then `~/.local/bin`, then `/opt/homebrew/bin`). Each question shows what was found; Enter accepts it. A second run asks nothing and changes nothing. It writes with the code of `keepsnappy config set`, to your config file (or `--config FILE`), and never writes a key. `--yes` asks nothing: a flag decides, else what was found, except a folder that only looks right by its name, which is shown with the flag that applies it. A folder is matched to the project by its remote against the project's repository URL (`repo_url` of the events page). `--detect` only looks (not with `--yes`, `--repo`, `--agent` or the path flags): per app it lists the folders found, `match: "remote"` when a remote is the project's URL and `"name"` when only the folder or a remote is named like the app (used when the URL is unknown). `--detect --json` prints `{"version":1,"apps":[{"app","repo_url","candidates":[{"path","remotes","match"}]}]}`, remote matches first.
|
|
330
|
+
|
|
331
|
+
`keepsnappy investigate <id> --preflight` does everything before the agent starts and calls no model: reads the package, checks the commit is in the repo, adds and removes the worktree, runs `<agent> --version`. It ends with `KEEPSNAPPY-STATUS preflight-ok` or fails with the usual statuses.
|
|
332
|
+
|
|
246
333
|
## Output layout
|
|
247
334
|
|
|
248
335
|
```
|
package/dist/appium-driver.js
CHANGED
|
@@ -307,6 +307,7 @@ var IOS_BOTTOM_INSET_PT = 34;
|
|
|
307
307
|
var GESTURE_ZONE_MARGIN_PT = 16;
|
|
308
308
|
var ANDROID_NAV_BAR_FALLBACK_DP = 48;
|
|
309
309
|
var GESTURE_ZONE_MARGIN_DP = 16;
|
|
310
|
+
var PROBE_FROM = 8;
|
|
310
311
|
|
|
311
312
|
class Driver {
|
|
312
313
|
bundleId;
|
|
@@ -427,8 +428,17 @@ class Driver {
|
|
|
427
428
|
return r.x >= 0 && r.y >= 0 && r.x + r.width <= w.width && r.y + r.height <= w.height;
|
|
428
429
|
return r.x < w.width && r.y < w.height && r.x + r.width > 0 && r.y + r.height > 0;
|
|
429
430
|
}
|
|
430
|
-
async findShown(sel, full = false) {
|
|
431
|
-
|
|
431
|
+
async findShown(sel, full = false, under, anyShown = false) {
|
|
432
|
+
try {
|
|
433
|
+
return await this.findShownOnce(sel, full, under, anyShown);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
if (sel.childOf === undefined || !(e instanceof WdError) || e.wdError !== "stale element reference")
|
|
436
|
+
throw e;
|
|
437
|
+
return this.findShownOnce(sel, full, under, anyShown);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async findShownOnce(sel, full, under, anyShown) {
|
|
441
|
+
let parent = under;
|
|
432
442
|
if (sel.childOf !== undefined) {
|
|
433
443
|
const p = await this.findShown(id(sel.childOf));
|
|
434
444
|
if (!p)
|
|
@@ -443,13 +453,44 @@ class Driver {
|
|
|
443
453
|
if (await this.onScreen(first, full))
|
|
444
454
|
return first;
|
|
445
455
|
}
|
|
446
|
-
|
|
456
|
+
const all = await this.findAll(using, value, parent);
|
|
457
|
+
const probed = all.length > PROBE_FROM ? await this.probeShown(all, full, anyShown) : undefined;
|
|
458
|
+
if (probed)
|
|
459
|
+
return probed;
|
|
460
|
+
for (const el of all) {
|
|
447
461
|
if (await this.onScreen(el, full))
|
|
448
462
|
return el;
|
|
449
463
|
}
|
|
450
464
|
}
|
|
451
465
|
return null;
|
|
452
466
|
}
|
|
467
|
+
async probeShown(els, full, anyShown = false) {
|
|
468
|
+
const w = await this.screen();
|
|
469
|
+
let lo = 0;
|
|
470
|
+
let hi = els.length - 1;
|
|
471
|
+
while (lo <= hi) {
|
|
472
|
+
const mid = lo + hi >> 1;
|
|
473
|
+
const el = els[mid];
|
|
474
|
+
if (await this.onScreen(el, full)) {
|
|
475
|
+
if (anyShown)
|
|
476
|
+
return el;
|
|
477
|
+
let first = mid;
|
|
478
|
+
while (first > 0 && await this.onScreen(els[first - 1], full))
|
|
479
|
+
first--;
|
|
480
|
+
return els[first];
|
|
481
|
+
}
|
|
482
|
+
const r = this.rects.get(el);
|
|
483
|
+
if (!r || r.width <= 0 || r.height <= 0)
|
|
484
|
+
return;
|
|
485
|
+
if (r.y + r.height <= 0)
|
|
486
|
+
lo = mid + 1;
|
|
487
|
+
else if (r.y >= w.height)
|
|
488
|
+
hi = mid - 1;
|
|
489
|
+
else
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
453
494
|
async shownMany(sels, group) {
|
|
454
495
|
const out = sels.map(() => false);
|
|
455
496
|
const tests = sels.map((s) => s.id !== undefined && s.text === undefined && s.childOf === undefined ? idMatcher(s.id) : null);
|
|
@@ -507,6 +548,25 @@ class Driver {
|
|
|
507
548
|
});
|
|
508
549
|
await this.cmd("DELETE", "/actions").catch(() => null);
|
|
509
550
|
}
|
|
551
|
+
async stroke(from, points, note) {
|
|
552
|
+
const safe = await this.safeBottomY();
|
|
553
|
+
const moves = points.map((p, i) => ({ type: "pointerMove", duration: p.ms, x: p.x, y: i === points.length - 1 ? Math.min(p.y, safe) : p.y }));
|
|
554
|
+
const logged = this.log.length;
|
|
555
|
+
try {
|
|
556
|
+
await this.cmd("POST", "/actions", {
|
|
557
|
+
actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: [
|
|
558
|
+
{ type: "pointerMove", duration: 0, x: from.x, y: from.y },
|
|
559
|
+
{ type: "pointerDown", button: 0 },
|
|
560
|
+
...moves,
|
|
561
|
+
{ type: "pointerUp", button: 0 }
|
|
562
|
+
] }]
|
|
563
|
+
});
|
|
564
|
+
} finally {
|
|
565
|
+
if (note && this.log[logged])
|
|
566
|
+
Object.assign(this.log[logged], note);
|
|
567
|
+
}
|
|
568
|
+
await this.cmd("DELETE", "/actions").catch(() => null);
|
|
569
|
+
}
|
|
510
570
|
async drag(from, to, durationMs, note) {
|
|
511
571
|
to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
|
|
512
572
|
const logged = this.log.length;
|
|
@@ -734,10 +794,18 @@ var FLING = {
|
|
|
734
794
|
contactMs: 150,
|
|
735
795
|
startMargin: 0.1
|
|
736
796
|
};
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
797
|
+
var NAV_STROKE = {
|
|
798
|
+
end: [0.9, 0.1],
|
|
799
|
+
top: [0.2, 1],
|
|
800
|
+
slow: 0.1,
|
|
801
|
+
slowMs: 100,
|
|
802
|
+
fastMs: 45,
|
|
803
|
+
minTravel: 250,
|
|
804
|
+
precise: { margin: 0.2, moveMs: 600, holdMs: 150 }
|
|
805
|
+
};
|
|
806
|
+
function fit(r, f, t, minTravel, unit, safeBottom, start) {
|
|
807
|
+
const travel = Math.round(Math.max(Math.abs(t - f) * r.height, minTravel * unit));
|
|
808
|
+
const step = t > f ? travel : -travel;
|
|
741
809
|
const clamp = (y) => Math.min(Math.max(y, 0), safeBottom);
|
|
742
810
|
let from = Math.round(start ?? r.y + r.height * f);
|
|
743
811
|
const below = Math.max(from, from + step) - safeBottom;
|
|
@@ -750,16 +818,38 @@ function flingStroke(r, toward, unit, safeBottom, start) {
|
|
|
750
818
|
const to = clamp(from + step);
|
|
751
819
|
return { from, to, travel, clamped: Math.abs(to - from) < travel };
|
|
752
820
|
}
|
|
821
|
+
function flingStroke(r, toward, unit, safeBottom, start) {
|
|
822
|
+
const [f, t] = FLING[toward];
|
|
823
|
+
return fit(r, f, t, FLING.minTravel, unit, safeBottom, start);
|
|
824
|
+
}
|
|
825
|
+
function navStroke(r, toward, unit, safeBottom) {
|
|
826
|
+
const [f, t] = NAV_STROKE[toward];
|
|
827
|
+
const s = fit(r, f, t, NAV_STROKE.minTravel, unit, safeBottom);
|
|
828
|
+
const slow = Math.min(Math.round(NAV_STROKE.slow * r.height), Math.floor(Math.abs(s.to - s.from) / 2));
|
|
829
|
+
return { ...s, mid: s.from + (s.to > s.from ? slow : -slow) };
|
|
830
|
+
}
|
|
753
831
|
|
|
754
832
|
// packages/appium-driver/src/scroll.ts
|
|
755
833
|
var COAST_POLL_MS = 300;
|
|
756
834
|
var COAST_MAX_MS = 3000;
|
|
757
|
-
var FLING_UNMOVED_MS = 4500;
|
|
758
835
|
|
|
759
836
|
class GestureNotTaken extends Error {
|
|
760
837
|
name = "GestureNotTaken";
|
|
761
838
|
}
|
|
762
|
-
async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false, coastMaxMs = COAST_MAX_MS } = {}) {
|
|
839
|
+
async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false, coastMaxMs = COAST_MAX_MS, precise = false } = {}) {
|
|
840
|
+
if (t.navigating) {
|
|
841
|
+
await t.ready();
|
|
842
|
+
let rect = at;
|
|
843
|
+
if (!rect) {
|
|
844
|
+
if (!spec.list)
|
|
845
|
+
throw new Error("a navigation stroke needs the list (or its frame)");
|
|
846
|
+
const c = cache ?? {};
|
|
847
|
+
c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
|
|
848
|
+
rect = c.at.rect;
|
|
849
|
+
}
|
|
850
|
+
await navGesture(t, rect, toward, precise);
|
|
851
|
+
return { outcome: "moved", retried: false, sentAt: t.now() };
|
|
852
|
+
}
|
|
763
853
|
if (spec.screen) {
|
|
764
854
|
if (t.driver.platform.name === "ios" && spec.row !== undefined)
|
|
765
855
|
await drawnRowDrag(t, spec, toward);
|
|
@@ -774,15 +864,19 @@ async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false,
|
|
|
774
864
|
const c = cache ?? {};
|
|
775
865
|
const checked = mustMove && spec.row !== undefined;
|
|
776
866
|
const ios = t.driver.platform.name === "ios";
|
|
777
|
-
const held = checked
|
|
867
|
+
const held = checked ? hold ?? (c.hold ??= {}) : hold;
|
|
778
868
|
const send = async (first) => {
|
|
779
869
|
c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
|
|
780
870
|
if (first && held)
|
|
781
871
|
await holdBeforeScroll(t, spec.row, handle(spec, c), spec.field, held);
|
|
782
|
-
|
|
872
|
+
let before = checked ? await heldFrame(t, held) : null;
|
|
873
|
+
if (checked && before === null) {
|
|
874
|
+
await holdBeforeScroll(t, spec.row, handle(spec, c), spec.field, held);
|
|
875
|
+
before = await heldFrame(t, held);
|
|
876
|
+
}
|
|
783
877
|
const g = await listGesture(t, c.at.rect, toward);
|
|
784
878
|
const after = before ? await heldFrame(t, held) : null;
|
|
785
|
-
const suspect =
|
|
879
|
+
const suspect = before !== null && before === after;
|
|
786
880
|
return { ...g, suspect };
|
|
787
881
|
};
|
|
788
882
|
const first = await send(true);
|
|
@@ -890,22 +984,38 @@ async function listGesture(t, r, toward) {
|
|
|
890
984
|
if (android) {
|
|
891
985
|
const logged = t.driver.log.length;
|
|
892
986
|
const sent = t.now();
|
|
893
|
-
const answer = await t.driver.execute("mobile:
|
|
987
|
+
const answer = await t.driver.execute("mobile: swipeGesture", {
|
|
894
988
|
left: r.x,
|
|
895
989
|
top: Math.min(s.from, s.to),
|
|
896
990
|
width: r.width,
|
|
897
991
|
height: Math.abs(s.to - s.from),
|
|
898
|
-
direction: toward === "end" ? "
|
|
992
|
+
direction: toward === "end" ? "up" : "down",
|
|
993
|
+
percent: 1,
|
|
899
994
|
speed: Math.round(1000 * Math.abs(s.to - s.from) / FLING.contactMs)
|
|
900
995
|
});
|
|
901
996
|
const answerMs = t.now() - sent;
|
|
902
997
|
if (note && t.driver.log[logged])
|
|
903
998
|
Object.assign(t.driver.log[logged], note);
|
|
904
|
-
return { outcome:
|
|
999
|
+
return { outcome: "moved", answer, answerMs, sentAt: t.now() };
|
|
905
1000
|
}
|
|
906
1001
|
await drag(t, Math.round(r.x + r.width / 2), s, note);
|
|
907
1002
|
return { outcome: "moved", sentAt: t.now() };
|
|
908
1003
|
}
|
|
1004
|
+
async function navGesture(t, r, toward, precise = false) {
|
|
1005
|
+
const android = t.driver.platform.name === "android";
|
|
1006
|
+
const x = Math.round(r.x + r.width / 2);
|
|
1007
|
+
if (precise) {
|
|
1008
|
+
const p = NAV_STROKE.precise;
|
|
1009
|
+
const [a, b] = toward === "end" ? [1 - p.margin, p.margin] : [p.margin, 1 - p.margin];
|
|
1010
|
+
const safe = await t.driver.safeBottomY();
|
|
1011
|
+
const from = Math.min(Math.round(r.y + r.height * a), safe);
|
|
1012
|
+
const to = Math.min(Math.round(r.y + r.height * b), safe);
|
|
1013
|
+
await t.driver.stroke({ x, y: from }, [{ x, y: to, ms: p.moveMs }, { x, y: to, ms: p.holdMs }], { nav: true, precise: true });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const s = navStroke(r, toward, android ? await t.driver.density() : 1, await t.driver.safeBottomY());
|
|
1017
|
+
await t.driver.stroke({ x, y: s.from }, [{ x, y: s.mid, ms: NAV_STROKE.slowMs }, { x, y: s.to, ms: NAV_STROKE.fastMs }], { nav: true, ...s.clamped ? { clamped: true, travel: Math.abs(s.to - s.from) } : {} });
|
|
1018
|
+
}
|
|
909
1019
|
var drag = (t, x, s, note) => t.driver.drag({ x, y: s.from }, { x, y: s.to }, FLING.contactMs, note);
|
|
910
1020
|
async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn row to scroll from" }, toward) {
|
|
911
1021
|
await t.ready();
|
|
@@ -952,7 +1062,7 @@ async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn ro
|
|
|
952
1062
|
}
|
|
953
1063
|
async function firstDrawn(t, row, listEl, field, scopedOrField) {
|
|
954
1064
|
if (!scopedOrField) {
|
|
955
|
-
return t.driver.findShown(toSelector(row));
|
|
1065
|
+
return t.driver.findShown(toSelector(row), false, undefined, true);
|
|
956
1066
|
}
|
|
957
1067
|
const { childOf: _list, ...inList } = toSelector(row);
|
|
958
1068
|
const all = t.driver.platform.locators(inList);
|
|
@@ -1030,7 +1140,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
1030
1140
|
import { fileURLToPath } from "node:url";
|
|
1031
1141
|
var DRIVER_NAME = "appium";
|
|
1032
1142
|
function driverVersion() {
|
|
1033
|
-
const built = "0.1.0-alpha.
|
|
1143
|
+
const built = "0.1.0-alpha.24";
|
|
1034
1144
|
if (built)
|
|
1035
1145
|
return built;
|
|
1036
1146
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1160,6 +1270,8 @@ var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
|
|
|
1160
1270
|
var FLUSH_POLL_MS = 500;
|
|
1161
1271
|
var FLUSH_RESEND_MS = 90000;
|
|
1162
1272
|
var ACTIVATION_SETTLE_MS = 2000;
|
|
1273
|
+
var ACTIVATION_CHECK_N = 999999;
|
|
1274
|
+
var HOME_READY_MS = 30000;
|
|
1163
1275
|
var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
|
|
1164
1276
|
var flushLoss = (detail) => {
|
|
1165
1277
|
if (!detail)
|
|
@@ -1193,9 +1305,14 @@ var RESCUE_EVERY_MS = 3000;
|
|
|
1193
1305
|
var noop = async () => {
|
|
1194
1306
|
return;
|
|
1195
1307
|
};
|
|
1308
|
+
var NAV_STEP = "nav";
|
|
1309
|
+
var NAV_EDGE_MS = 300;
|
|
1196
1310
|
var RETRY_COMMAND = /-retry$/;
|
|
1197
|
-
function harnessDefects(commands, gestureRetries) {
|
|
1311
|
+
function harnessDefects(commands, gestureRetries, navigation = {}) {
|
|
1198
1312
|
const out = { gestureRetry: gestureRetries };
|
|
1313
|
+
for (const [name, n] of Object.entries(navigation))
|
|
1314
|
+
if (n)
|
|
1315
|
+
out[name] = n;
|
|
1199
1316
|
for (const c of commands)
|
|
1200
1317
|
if (RETRY_COMMAND.test(c.cmd))
|
|
1201
1318
|
out[c.cmd] = (out[c.cmd] ?? 0) + 1;
|
|
@@ -1218,7 +1335,8 @@ class Session {
|
|
|
1218
1335
|
gcN = 0;
|
|
1219
1336
|
stepDepth = 0;
|
|
1220
1337
|
stepLog = [];
|
|
1221
|
-
defects = { gestureRetry: 0, items: [] };
|
|
1338
|
+
defects = { gestureRetry: 0, navStuck: 0, navCatchUp: 0, items: [] };
|
|
1339
|
+
navigating = false;
|
|
1222
1340
|
onStepChange = null;
|
|
1223
1341
|
refinds = 0;
|
|
1224
1342
|
get staleRefinds() {
|
|
@@ -1279,10 +1397,34 @@ class Session {
|
|
|
1279
1397
|
this.driver.log.push({ ...this.driver.context, cmd: "ready", t: this.now(), ms: took, status: 0 });
|
|
1280
1398
|
return took;
|
|
1281
1399
|
}
|
|
1400
|
+
async navigation(fn) {
|
|
1401
|
+
if (this.stepDepth > 0)
|
|
1402
|
+
throw new Error("a navigation pass cannot run inside a measured step");
|
|
1403
|
+
if (this.navigating)
|
|
1404
|
+
throw new Error("a navigation pass cannot run inside another navigation pass");
|
|
1405
|
+
this.navigating = true;
|
|
1406
|
+
this.marker(NAV_STEP, "start");
|
|
1407
|
+
try {
|
|
1408
|
+
await this.driver.sleep(NAV_EDGE_MS);
|
|
1409
|
+
return await fn();
|
|
1410
|
+
} finally {
|
|
1411
|
+
await this.driver.sleep(NAV_EDGE_MS);
|
|
1412
|
+
this.marker(NAV_STEP, "end");
|
|
1413
|
+
this.navigating = false;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
navigationUnlessStep(fn) {
|
|
1417
|
+
return this.stepDepth > 0 || this.navigating ? fn() : this.navigation(fn);
|
|
1418
|
+
}
|
|
1419
|
+
declaresSteps() {
|
|
1420
|
+
return this.markers.some((m) => m.phase === "start" && m.step !== NAV_STEP);
|
|
1421
|
+
}
|
|
1282
1422
|
marker(step, phase, gcN = null) {
|
|
1283
1423
|
this.markers.push({ token: this.token, step, phase, t_host: this.now(), ...gcN !== null ? { gc_n: gcN } : {} });
|
|
1284
1424
|
}
|
|
1285
1425
|
async step(name, action, checks = noop, once = noop) {
|
|
1426
|
+
if (this.navigating)
|
|
1427
|
+
throw new Error("a measured step cannot start inside a navigation pass");
|
|
1286
1428
|
await this.ready();
|
|
1287
1429
|
this.driver.context = { ...this.driver.context, step: name };
|
|
1288
1430
|
const record = { journey: this.driver.context.journey, iteration: this.driver.context.iteration, name, depth: this.stepDepth, t: this.now(), ms: null };
|
|
@@ -1448,7 +1590,11 @@ class Session {
|
|
|
1448
1590
|
if (!await this.isVisible(target))
|
|
1449
1591
|
throw new Error(`${describeSelector(toSelector(target))} not visible after ${times} swipes from ${describeSelector(toSelector(from))}`);
|
|
1450
1592
|
}
|
|
1451
|
-
async scrollUntil(sel,
|
|
1593
|
+
async scrollUntil(sel, options = {}) {
|
|
1594
|
+
await this.ready();
|
|
1595
|
+
return this.navigationUnlessStep(() => this.scrollWindowUntil(sel, options));
|
|
1596
|
+
}
|
|
1597
|
+
async scrollWindowUntil(sel, { direction = "down", timeoutMs = 15000 }) {
|
|
1452
1598
|
const s = toSelector(sel);
|
|
1453
1599
|
const deadline = this.now() + timeoutMs;
|
|
1454
1600
|
let lookUntil = 0;
|
|
@@ -1517,7 +1663,11 @@ class Session {
|
|
|
1517
1663
|
this.driver.log.push({ ...this.driver.context, cmd: `fling-${toward}-times`, t: this.now(), ms: this.now() - start, status, flings, refinds: this.refinds - refindsBefore });
|
|
1518
1664
|
}
|
|
1519
1665
|
}
|
|
1520
|
-
async flingUntil(list, target, toward,
|
|
1666
|
+
async flingUntil(list, target, toward, options = {}) {
|
|
1667
|
+
await this.ready();
|
|
1668
|
+
return this.navigationUnlessStep(() => this.flingToTarget(list, target, toward, options));
|
|
1669
|
+
}
|
|
1670
|
+
async flingToTarget(list, target, toward, { blind = 0, timeoutMs = FLING_TIMEOUT_MS }) {
|
|
1521
1671
|
await this.ready();
|
|
1522
1672
|
const start = this.now();
|
|
1523
1673
|
const refindsBefore = this.refinds;
|
|
@@ -1597,14 +1747,27 @@ class Session {
|
|
|
1597
1747
|
this.token = token;
|
|
1598
1748
|
if (token)
|
|
1599
1749
|
this.opened.add(token);
|
|
1600
|
-
this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry };
|
|
1750
|
+
this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry, navStuck: this.defects.navStuck, navCatchUp: this.defects.navCatchUp };
|
|
1601
1751
|
this.pendingReady = null;
|
|
1602
1752
|
await this.relaunch();
|
|
1603
|
-
await this.waitVisible(home,
|
|
1753
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1604
1754
|
const alert = await this.openLink(activationLink(this.scheme, token, args));
|
|
1605
1755
|
await this.driver.sleep(ACTIVATION_SETTLE_MS);
|
|
1756
|
+
await this.relaunch();
|
|
1757
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1758
|
+
if (token && this.linkState.linkMode !== "safari")
|
|
1759
|
+
await this.confirmActive();
|
|
1606
1760
|
return alert;
|
|
1607
1761
|
}
|
|
1762
|
+
async confirmActive() {
|
|
1763
|
+
const n = ACTIVATION_CHECK_N;
|
|
1764
|
+
const start = this.now();
|
|
1765
|
+
const answer = await this.driver.platform.memReadCommand(this.driver, n, this.scheme, this.linkState).catch(() => null);
|
|
1766
|
+
const ok = answer?.startsWith(`memread:${n} `) ?? false;
|
|
1767
|
+
this.driver.log.push({ ...this.driver.context, cmd: "activation-check", t: this.now(), ms: this.now() - start, status: ok ? 0 : 1, ...answer !== null ? { answer } : {} });
|
|
1768
|
+
if (!ok)
|
|
1769
|
+
throw new Error(`SDK did not activate: the launch after the activation link answered no command (memread:${n}${answer === null ? ", no answer" : `, got ${answer}`})`);
|
|
1770
|
+
}
|
|
1608
1771
|
opened = new Set;
|
|
1609
1772
|
told = new Set;
|
|
1610
1773
|
runFrom = null;
|
|
@@ -1612,10 +1775,10 @@ class Session {
|
|
|
1612
1775
|
lastFlush = null;
|
|
1613
1776
|
flushSeq = 0;
|
|
1614
1777
|
async endRun() {
|
|
1615
|
-
if (this.
|
|
1778
|
+
if (this.declaresSteps())
|
|
1616
1779
|
await this.memRead(await this.gc(true));
|
|
1617
1780
|
if (this.runFrom && this.token)
|
|
1618
|
-
this.runDefects.set(this.token, harnessDefects(this.driver.log.slice(this.runFrom.commands), this.defects.gestureRetry - this.runFrom.gestureRetries));
|
|
1781
|
+
this.runDefects.set(this.token, harnessDefects(this.driver.log.slice(this.runFrom.commands), this.defects.gestureRetry - this.runFrom.gestureRetries, { navStuck: this.defects.navStuck - this.runFrom.navStuck, navCatchUp: this.defects.navCatchUp - this.runFrom.navCatchUp }));
|
|
1619
1782
|
this.runFrom = null;
|
|
1620
1783
|
await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, capture: this.capture, defects: (token) => this.runDefects.get(token), tokens: [...this.opened], reported: this.told, note: (line) => this.driver.log.push({ ...this.driver.context, cmd: "driver-report", t: this.now(), ms: 0, status: 1, answer: line }) });
|
|
1621
1784
|
const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
|
|
@@ -2005,7 +2168,7 @@ async function runIterations(t, module, ctx, iterations, progress) {
|
|
|
2005
2168
|
await t.ready();
|
|
2006
2169
|
const t2 = t.now();
|
|
2007
2170
|
t.marker("run:complete", "end");
|
|
2008
|
-
rec.steps = t.markers.filter((m) => m.phase === "start").length;
|
|
2171
|
+
rec.steps = t.markers.filter((m) => m.phase === "start" && m.step !== NAV_STEP).length;
|
|
2009
2172
|
const waits = t.readyWaits.slice(waitsBefore);
|
|
2010
2173
|
rec.ready = { n: waits.length, ms: waits.reduce((x, y) => x + y, 0) };
|
|
2011
2174
|
driver.context = { ...driver.context, phase: "end" };
|