@keepsnappy/agent 0.1.0-alpha.23 → 0.1.0-alpha.25
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 +8 -6
- package/dist/appium-driver.js +71 -13
- package/dist/keepsnappy.js +13863 -11079
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ keepsnappy run -- maestro test --include-tags perf e2e/flows/ # wrap your own
|
|
|
37
37
|
keepsnappy run --upload --wait # send the results; the server judges them and the CLI prints its verdict
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Flags: `--config` (default `keepsnappy.yml`), `--repeat N`, `--out DIR`, `--upload [--ingest-url URL]`, `--wait`, `--fail-on-worse`, `--commit SHA` / `--branch NAME` (default `GITHUB_SHA` / `GITHUB_HEAD_REF` on a pull request, else `GITHUB_REF_NAME`), `--base-commit SHA`, `--device-model` / `--device-os` (default: read from
|
|
40
|
+
Flags: `--config` (default `keepsnappy.yml`), `--repeat N`, `--out DIR`, `--upload [--ingest-url URL]`, `--wait`, `--fail-on-worse`, `--commit SHA` / `--branch NAME` (default `GITHUB_SHA` / `GITHUB_HEAD_REF` on a pull request, else `GITHUB_REF_NAME`), `--base-commit SHA`, `--device UDID` (the simulator or iPhone to run on; default `device:` in keepsnappy.yml, else the only booted simulator; with several booted and none named the run refuses and lists them; every Maestro command gets it as `maestro --device UDID`), `--device-model` / `--device-os` (default: read from that simulator), `--scenario NAME` (flow name for a command given after `--`), `--replay <out dir>[:ARM]` (repeatable: take recorded runs instead of running anything), `--no-markers`, `--clock-offset-ms`.
|
|
41
41
|
|
|
42
42
|
Commit details: when a run set opens (`keepsnappy run --upload`, `keepsnappy farm open`), the CLI reads the subject, author
|
|
43
43
|
name and committer date of `--commit` and `--base-commit` with `git log` in the checkout (argv only, 5 s timeout) and
|
|
@@ -60,7 +60,7 @@ Annotated example: `examples/keepsnappy.yml`. Every setting:
|
|
|
60
60
|
| `project.key_env` | Name of the env var with the project key (default `KEEPSNAPPY_KEY`). Only read with `--upload`. |
|
|
61
61
|
| `project.ingest_url` | KeepSnappy API base URL for `--upload` (or `--ingest-url`, or env `KEEPSNAPPY_API`). Defaults to the hosted KeepSnappy API. |
|
|
62
62
|
| `app.bundle_id` | Required. App whose keepsnappy log is read after each run. |
|
|
63
|
-
| `platform`, `device` | `ios-sim` (default; `device` = UDID or `booted`) or `ios-device` (UDID required). One device per invocation in v1. |
|
|
63
|
+
| `platform`, `device` | `ios-sim` (default; `device` = UDID, or `booted` for the only booted simulator: with several booted, name one) or `ios-device` (UDID required). One device per invocation in v1. |
|
|
64
64
|
| `repetitions` | Runs of the whole flow set (default 5). |
|
|
65
65
|
| `flows.runner` | `maestro` (build `maestro test ...`) or `command` (run `flows.command`). |
|
|
66
66
|
| `flows.tags`, `flows.exclude_tags` | `--include-tags=` / `--exclude-tags=`. |
|
|
@@ -223,12 +223,12 @@ What it does, in order:
|
|
|
223
223
|
1. Reads the investigation package with the same client as the MCP tool `get_investigation` (stored results only).
|
|
224
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).
|
|
225
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.
|
|
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
|
|
227
|
-
4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-
|
|
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
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
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.
|
|
230
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.
|
|
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. An answer of no hypothesis stores nothing.
|
|
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
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
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
234
|
|
|
@@ -292,6 +292,8 @@ investigate:
|
|
|
292
292
|
|
|
293
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
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
|
+
|
|
295
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:
|
|
296
298
|
|
|
297
299
|
```sh
|
|
@@ -303,7 +305,7 @@ keepsnappy config list # the thre
|
|
|
303
305
|
keepsnappy config set investigate.codexPath /opt/codex/bin/codex --config ./my.yml # --config FILE: another file instead of your own
|
|
304
306
|
```
|
|
305
307
|
|
|
306
|
-
`set` and `unset` accept only `investigate.agent`, `investigate.claudePath`, `investigate.codexPath` 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).
|
|
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).
|
|
307
309
|
|
|
308
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.
|
|
309
311
|
|
package/dist/appium-driver.js
CHANGED
|
@@ -428,8 +428,17 @@ class Driver {
|
|
|
428
428
|
return r.x >= 0 && r.y >= 0 && r.x + r.width <= w.width && r.y + r.height <= w.height;
|
|
429
429
|
return r.x < w.width && r.y < w.height && r.x + r.width > 0 && r.y + r.height > 0;
|
|
430
430
|
}
|
|
431
|
-
async findShown(sel, full = false) {
|
|
432
|
-
|
|
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;
|
|
433
442
|
if (sel.childOf !== undefined) {
|
|
434
443
|
const p = await this.findShown(id(sel.childOf));
|
|
435
444
|
if (!p)
|
|
@@ -445,7 +454,7 @@ class Driver {
|
|
|
445
454
|
return first;
|
|
446
455
|
}
|
|
447
456
|
const all = await this.findAll(using, value, parent);
|
|
448
|
-
const probed = all.length > PROBE_FROM ? await this.probeShown(all, full) : undefined;
|
|
457
|
+
const probed = all.length > PROBE_FROM ? await this.probeShown(all, full, anyShown) : undefined;
|
|
449
458
|
if (probed)
|
|
450
459
|
return probed;
|
|
451
460
|
for (const el of all) {
|
|
@@ -455,7 +464,7 @@ class Driver {
|
|
|
455
464
|
}
|
|
456
465
|
return null;
|
|
457
466
|
}
|
|
458
|
-
async probeShown(els, full) {
|
|
467
|
+
async probeShown(els, full, anyShown = false) {
|
|
459
468
|
const w = await this.screen();
|
|
460
469
|
let lo = 0;
|
|
461
470
|
let hi = els.length - 1;
|
|
@@ -463,6 +472,8 @@ class Driver {
|
|
|
463
472
|
const mid = lo + hi >> 1;
|
|
464
473
|
const el = els[mid];
|
|
465
474
|
if (await this.onScreen(el, full)) {
|
|
475
|
+
if (anyShown)
|
|
476
|
+
return el;
|
|
466
477
|
let first = mid;
|
|
467
478
|
while (first > 0 && await this.onScreen(els[first - 1], full))
|
|
468
479
|
first--;
|
|
@@ -535,7 +546,6 @@ class Driver {
|
|
|
535
546
|
}
|
|
536
547
|
]
|
|
537
548
|
});
|
|
538
|
-
await this.cmd("DELETE", "/actions").catch(() => null);
|
|
539
549
|
}
|
|
540
550
|
async stroke(from, points, note) {
|
|
541
551
|
const safe = await this.safeBottomY();
|
|
@@ -651,7 +661,7 @@ async function iosCommand(driver, link, cmd, n, state, waitMs) {
|
|
|
651
661
|
}
|
|
652
662
|
var ANDROID = {
|
|
653
663
|
name: "android",
|
|
654
|
-
defaultDevice: "Google Pixel
|
|
664
|
+
defaultDevice: "Google Pixel 9 Pro-15.0",
|
|
655
665
|
caps: (device) => ({
|
|
656
666
|
platformName: "Android",
|
|
657
667
|
"appium:automationName": "UiAutomator2",
|
|
@@ -926,7 +936,7 @@ async function rowsInList(t, spec, c) {
|
|
|
926
936
|
const android = t.driver.platform.name === "android";
|
|
927
937
|
const call = spec.field === "description" ? /descriptionMatches/ : /textMatches/;
|
|
928
938
|
const own = android && spec.field ? all.filter((l) => call.test(l.value)) : all;
|
|
929
|
-
const attr = sel
|
|
939
|
+
const attr = rowAttr(t, sel, spec.field);
|
|
930
940
|
const frames = [];
|
|
931
941
|
const inside = [];
|
|
932
942
|
const parent = spec.scoped ?? true ? c.at.el : undefined;
|
|
@@ -952,6 +962,10 @@ async function rowsInList(t, spec, c) {
|
|
|
952
962
|
ids.push(String(await t.driver.attribute(el, attr).catch(() => null) ?? ""));
|
|
953
963
|
return { ids, frames, list };
|
|
954
964
|
}
|
|
965
|
+
var rowAttr = (t, sel, field) => {
|
|
966
|
+
const android = t.driver.platform.name === "android";
|
|
967
|
+
return sel.text !== undefined ? android ? field === "description" ? "content-desc" : "text" : "label" : android ? "resource-id" : "name";
|
|
968
|
+
};
|
|
955
969
|
function room(t, { frames, list }, toward) {
|
|
956
970
|
if (frames.length === 0)
|
|
957
971
|
return false;
|
|
@@ -1051,7 +1065,7 @@ async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn ro
|
|
|
1051
1065
|
}
|
|
1052
1066
|
async function firstDrawn(t, row, listEl, field, scopedOrField) {
|
|
1053
1067
|
if (!scopedOrField) {
|
|
1054
|
-
return t.driver.findShown(toSelector(row));
|
|
1068
|
+
return t.driver.findShown(toSelector(row), false, undefined, true);
|
|
1055
1069
|
}
|
|
1056
1070
|
const { childOf: _list, ...inList } = toSelector(row);
|
|
1057
1071
|
const all = t.driver.platform.locators(inList);
|
|
@@ -1129,7 +1143,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
1129
1143
|
import { fileURLToPath } from "node:url";
|
|
1130
1144
|
var DRIVER_NAME = "appium";
|
|
1131
1145
|
function driverVersion() {
|
|
1132
|
-
const built = "0.1.0-alpha.
|
|
1146
|
+
const built = "0.1.0-alpha.25";
|
|
1133
1147
|
if (built)
|
|
1134
1148
|
return built;
|
|
1135
1149
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1259,6 +1273,8 @@ var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
|
|
|
1259
1273
|
var FLUSH_POLL_MS = 500;
|
|
1260
1274
|
var FLUSH_RESEND_MS = 90000;
|
|
1261
1275
|
var ACTIVATION_SETTLE_MS = 2000;
|
|
1276
|
+
var ACTIVATION_CHECK_N = 999999;
|
|
1277
|
+
var HOME_READY_MS = 30000;
|
|
1262
1278
|
var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
|
|
1263
1279
|
var flushLoss = (detail) => {
|
|
1264
1280
|
if (!detail)
|
|
@@ -1321,6 +1337,7 @@ class Session {
|
|
|
1321
1337
|
capture;
|
|
1322
1338
|
gcN = 0;
|
|
1323
1339
|
stepDepth = 0;
|
|
1340
|
+
beforeLaunchOpen = false;
|
|
1324
1341
|
stepLog = [];
|
|
1325
1342
|
defects = { gestureRetry: 0, navStuck: 0, navCatchUp: 0, items: [] };
|
|
1326
1343
|
navigating = false;
|
|
@@ -1339,6 +1356,7 @@ class Session {
|
|
|
1339
1356
|
this.capture = o.capture;
|
|
1340
1357
|
}
|
|
1341
1358
|
async gc(atEnd = false) {
|
|
1359
|
+
this.notInBeforeLaunch("GC command");
|
|
1342
1360
|
if (this.actionGc !== "step" && !(atEnd && this.endGc && this.actionGc === "off"))
|
|
1343
1361
|
return null;
|
|
1344
1362
|
if (this.linkState.linkMode === "safari") {
|
|
@@ -1356,6 +1374,7 @@ class Session {
|
|
|
1356
1374
|
}
|
|
1357
1375
|
memReadOff = false;
|
|
1358
1376
|
async memRead(n) {
|
|
1377
|
+
this.notInBeforeLaunch("memory read");
|
|
1359
1378
|
if (n === null || this.memReadOff || this.linkState.linkMode === "safari")
|
|
1360
1379
|
return false;
|
|
1361
1380
|
const start = this.now();
|
|
@@ -1385,6 +1404,7 @@ class Session {
|
|
|
1385
1404
|
return took;
|
|
1386
1405
|
}
|
|
1387
1406
|
async navigation(fn) {
|
|
1407
|
+
this.notInBeforeLaunch("navigation pass");
|
|
1388
1408
|
if (this.stepDepth > 0)
|
|
1389
1409
|
throw new Error("a navigation pass cannot run inside a measured step");
|
|
1390
1410
|
if (this.navigating)
|
|
@@ -1406,10 +1426,16 @@ class Session {
|
|
|
1406
1426
|
declaresSteps() {
|
|
1407
1427
|
return this.markers.some((m) => m.phase === "start" && m.step !== NAV_STEP);
|
|
1408
1428
|
}
|
|
1429
|
+
notInBeforeLaunch(what) {
|
|
1430
|
+
if (this.beforeLaunchOpen)
|
|
1431
|
+
throw new Error(`no step or marker in beforeLaunch (${what}): it runs before the measured launch`);
|
|
1432
|
+
}
|
|
1409
1433
|
marker(step, phase, gcN = null) {
|
|
1434
|
+
this.notInBeforeLaunch(`marker ${step}:${phase}`);
|
|
1410
1435
|
this.markers.push({ token: this.token, step, phase, t_host: this.now(), ...gcN !== null ? { gc_n: gcN } : {} });
|
|
1411
1436
|
}
|
|
1412
1437
|
async step(name, action, checks = noop, once = noop) {
|
|
1438
|
+
this.notInBeforeLaunch(`step ${name}`);
|
|
1413
1439
|
if (this.navigating)
|
|
1414
1440
|
throw new Error("a measured step cannot start inside a navigation pass");
|
|
1415
1441
|
await this.ready();
|
|
@@ -1730,18 +1756,39 @@ class Session {
|
|
|
1730
1756
|
activate() {
|
|
1731
1757
|
return this.driver.activate();
|
|
1732
1758
|
}
|
|
1733
|
-
async beginRun({ token, args = "", home }) {
|
|
1759
|
+
async beginRun({ token, args = "", home, beforeLaunch }) {
|
|
1734
1760
|
this.token = token;
|
|
1735
1761
|
if (token)
|
|
1736
1762
|
this.opened.add(token);
|
|
1737
1763
|
this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry, navStuck: this.defects.navStuck, navCatchUp: this.defects.navCatchUp };
|
|
1738
1764
|
this.pendingReady = null;
|
|
1739
1765
|
await this.relaunch();
|
|
1740
|
-
await this.waitVisible(home,
|
|
1766
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1741
1767
|
const alert = await this.openLink(activationLink(this.scheme, token, args));
|
|
1742
1768
|
await this.driver.sleep(ACTIVATION_SETTLE_MS);
|
|
1769
|
+
if (beforeLaunch) {
|
|
1770
|
+
this.beforeLaunchOpen = true;
|
|
1771
|
+
try {
|
|
1772
|
+
await beforeLaunch();
|
|
1773
|
+
} finally {
|
|
1774
|
+
this.beforeLaunchOpen = false;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
await this.relaunch();
|
|
1778
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1779
|
+
if (token && this.linkState.linkMode !== "safari")
|
|
1780
|
+
await this.confirmActive();
|
|
1743
1781
|
return alert;
|
|
1744
1782
|
}
|
|
1783
|
+
async confirmActive() {
|
|
1784
|
+
const n = ACTIVATION_CHECK_N;
|
|
1785
|
+
const start = this.now();
|
|
1786
|
+
const answer = await this.driver.platform.memReadCommand(this.driver, n, this.scheme, this.linkState).catch(() => null);
|
|
1787
|
+
const ok = answer?.startsWith(`memread:${n} `) ?? false;
|
|
1788
|
+
this.driver.log.push({ ...this.driver.context, cmd: "activation-check", t: this.now(), ms: this.now() - start, status: ok ? 0 : 1, ...answer !== null ? { answer } : {} });
|
|
1789
|
+
if (!ok)
|
|
1790
|
+
throw new Error(`SDK did not activate: the launch after the activation link answered no command (memread:${n}${answer === null ? ", no answer" : `, got ${answer}`})`);
|
|
1791
|
+
}
|
|
1745
1792
|
opened = new Set;
|
|
1746
1793
|
told = new Set;
|
|
1747
1794
|
runFrom = null;
|
|
@@ -2134,11 +2181,22 @@ async function runIterations(t, module, ctx, iterations, progress) {
|
|
|
2134
2181
|
progress();
|
|
2135
2182
|
driver.context = { journey: it.journey, iteration: it.iteration, phase: "begin" };
|
|
2136
2183
|
const t0 = t.now();
|
|
2137
|
-
|
|
2184
|
+
const itCtx = { ...ctx, journey: it.journey, iteration: it.iteration };
|
|
2185
|
+
const hook = module.beforeLaunch;
|
|
2186
|
+
const beforeLaunch = hook && (async () => {
|
|
2187
|
+
driver.context = { ...driver.context, phase: "before-launch" };
|
|
2188
|
+
try {
|
|
2189
|
+
await hook(t, itCtx);
|
|
2190
|
+
} catch (e) {
|
|
2191
|
+
throw new Error(`beforeLaunch failed (${it.journey} #${it.iteration}): ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
2192
|
+
}
|
|
2193
|
+
driver.context = { ...driver.context, phase: "begin" };
|
|
2194
|
+
});
|
|
2195
|
+
rec.alert = await t.beginRun({ token: it.token, home: module.home, ...module.sdkArgs ? { args: module.sdkArgs } : {}, ...beforeLaunch ? { beforeLaunch } : {} });
|
|
2138
2196
|
const t1 = t.now();
|
|
2139
2197
|
driver.context = { ...driver.context, phase: "journey" };
|
|
2140
2198
|
const waitsBefore = t.readyWaits.length;
|
|
2141
|
-
await module.journeys[it.journey](t,
|
|
2199
|
+
await module.journeys[it.journey](t, itCtx);
|
|
2142
2200
|
await t.ready();
|
|
2143
2201
|
const t2 = t.now();
|
|
2144
2202
|
t.marker("run:complete", "end");
|