@keepsnappy/agent 0.1.0-alpha.15 → 0.1.0-alpha.16

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 CHANGED
@@ -103,10 +103,12 @@ keepsnappy farm open --api https://keepsnappy-api-….run.app --branch main --co
103
103
  keepsnappy farm close --state farm-run/keepsnappy-farm.json --bs-build farm-run/browserstack.ios.json --settle-seconds 60 --wait
104
104
  ```
105
105
 
106
- `keepsnappy farm run` does the whole night in one command (docs/plans/54a): resolve the app (`--app eas:<id>|file|bs://`), `farm open`, an optional BrowserStack Local tunnel (`--tunnel-for localhost:<port>`, repeatable), the device run (`--driver maestro` with `--suite-dir`/`--execute`, or `--driver appium --journey-module FILE` for the built-in Appium driver, `@keepsnappy/appium-driver`, docs/plans/54b; `--driver-script FILE` still runs your own driver), `farm close`, and cleanup (tunnel stopped, tokens file reset) whatever happened. `--env KEY=VALUE` values may use `{tunnel:<port>}`, `{run_id}`, `{out}`, `{tokens_js}`, `{run_set_id}`. Exit codes as `farm close`; a failed device run is 1. `--dry-run` prints the plan with no request. Needs `KEEPSNAPPY_KEY`, `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` (and `EXPO_TOKEN` for `eas:`).
106
+ `keepsnappy farm run` does the whole night in one command (docs/plans/54a): resolve the app (`--app eas:<id>|file|bs://<id>|custom_id:<id>`), `farm open`, an optional BrowserStack Local tunnel (`--tunnel-for localhost:<port>`, repeatable), the device run (`--driver maestro` with `--suite-dir`/`--execute`, or `--driver appium --journey-module FILE` for the built-in Appium driver on iOS or Android, `@keepsnappy/appium-driver`, docs/plans/54b; `--driver-script FILE` still runs your own driver), `farm close`, and cleanup (tunnel stopped, tokens file reset) whatever happened. `--env KEY=VALUE` values may use `{tunnel:<port>}`, `{run_id}`, `{out}`, `{tokens_js}`, `{run_set_id}`. Pre-uploaded `bs://` and `custom_id:` app references pass through without a file upload. Appium uses `--timeout-minutes 80` by default: the driver receives SIGTERM at the timeout, then SIGKILL after `--kill-after-seconds 120` if it has not exited. A timeout is recorded in the failure message, including the terminating signal, and returns exit 1 even if the driver exits zero after SIGTERM. Exit codes as `farm close`; a failed device run is 1. `--dry-run` prints the plan with no request. Needs `KEEPSNAPPY_KEY`, `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` (and `EXPO_TOKEN` for `eas:`).
107
107
 
108
108
  Setup and completion (plan 51 stage 2): `--completion-marker` declares that the flow posts the step marker `run:complete` (end) at the end of every iteration, from the main flow and not from `onFlowComplete`; the API then marks a run without it invalid (`flow-incomplete`). `--driver-version`, `--app-build-id`, `--harness-version`, `--flag-set` go into every run's setup fingerprint; `farm close` adds the device farm's build id from `--bs-build`.
109
109
 
110
+ The model a phone is stored under is the hardware code the OS reports (`SM-S911B`, `iPhone17,1`); the marketing name is only shown. With `--platform android|ios`, `--device-provider local|usb|customer|simulator|emulator` (the default `browserstack` needs an explicit model: a farm's phone is not on this machine) and no `--device-model`, `farm open` reads it from the device: Android `adb [-s <serial>] shell getprop ro.product.model`, an iPhone's machine id from `xcrun devicectl list devices`, a simulator's name from `xcrun simctl list devices` (kept with its "Simulator" word, so it never joins a real phone's row). With `--platform ios` and a `--device-id` that `xcrun simctl` lists as a simulator, `farm open` opens the run set as a simulator whatever provider and model were typed: provider `simulator`, the model with the "Simulator" word. It reads nothing it cannot pin to exactly one device (`--device-id` picks one); then `--device-model` is required. A typed `--device-model` is for a device this machine cannot ask (a farm's phone) and is sent as given.
111
+
110
112
  `farm open` also sends `journeys[]`: with `--config keepsnappy.yml` and/or `--flows <files|dirs|globs,...>` each `--journeys` name is matched to the flow file named like it (title, description, source path as for `keepsnappy run`); without them, keys only.
111
113
 
112
114
  `farm open` makes a run set (`source: farm`, the device of `--device-model/--device-os`: the journey history is keyed by
@@ -176,25 +178,31 @@ same words (`packages/mcp/README.md`).
176
178
  ```sh
177
179
  export KEEPSNAPPY_KEY=pl_rk_… # a project key with read scope (a CI key with run scope works too)
178
180
  keepsnappy findings --branch feat/keepsnappy-integration # what got worse or better on a PR, worst first
181
+ keepsnappy status --branch feat/keepsnappy-integration # is my PR ok? one answer: blocked / nothing blocks / ok, per journey × phone, ids of the worst findings, links
179
182
  keepsnappy findings --main --journey live-ui --phone "iPhone 15"
180
183
  keepsnappy findings --state all --json # also the resolved and not judged ones, as the API's JSON
181
184
  keepsnappy findings --state all --offset 200 # the next page (at most 200 per answer)
182
185
  keepsnappy finding f_0123456789abcdef # one finding with its evidence
183
186
  keepsnappy finding f_0123456789abcdef --prompt | pbcopy # only the hand-off for a coding agent, ending with its id and page
184
187
  keepsnappy compare <runA> <runB> # two runs (ids or external ids), window by window, no statistics
185
- keepsnappy history live-ui "Tap: live chat send" # one window on main, run by run (id or name)
188
+ keepsnappy history live-ui press:live.chat.send # one window on main, run by run (exact place id)
186
189
  keepsnappy commits a1b2c3d c3d4e5f [--branch main] # the measured commits between two points
190
+ keepsnappy places --journey live-ui --phone "iPhone 15" # every place of the newest main session from its stored statistics, no verdict
191
+ keepsnappy places --run-set <id> --measure commit_max_ms --sort p90 --limit 10
187
192
  keepsnappy layers # which layer answers which question, and its certificate
193
+ keepsnappy findings --branch feat/x --short # the short form: what decides, no evidence detail (also status, finding, history, places, main, trend)
194
+ keepsnappy trend live-ui Tap: live chat send --phone "iPhone 15" # the words after the journey are one window name; more windows with repeated --window
188
195
  ```
189
196
 
190
- Flags: `--api URL` (or `KEEPSNAPPY_API`, default the hosted API), `--key-env NAME` (default `KEEPSNAPPY_KEY`), `--json`.
197
+ Flags: `--api URL` (or `KEEPSNAPPY_API`, default the hosted API), `--key-env NAME` (default `KEEPSNAPPY_KEY`), `--json`, `--short`.
198
+ `--short` prints the same text with the evidence detail left out (the MCP tools' `form: "short"`); its last line says what was left out, and the MCP tool descriptions list it per answer. `--json` is the same with or without it.
191
199
  A finding's id is stable: the same window, measure, journey and phone model keep the id across commits and PRs, so
192
200
  `keepsnappy finding <id>` after a fix tells whether it is resolved. `--state` (default `present`) filters on the state on
193
201
  the asked branch: `present` (in the newest run set), `resolved` (the newest run set judged the measure: No change, or the
194
202
  other way), `not-judged` (the newest run set did not judge it: not measured, unstable, not enough data; it may still be
195
203
  there). A list starts with whether and when the branch was measured ("No KeepSnappy measurement on branch X", or the
196
204
  newest measured commit, its word and runs), so an empty list on an unmeasured branch does not read as clean. `commits`
197
- lists each measured commit once, with how many sessions measured it. Words come from the one word table (Worse / Warning / Better …), "Worse (not blocking)" when the
205
+ lists each measured commit once, with how many sessions measured it. `places` lists the places of one session (a run set, or the newest closed one of a journey on a phone and branch) with each measure's median, spread and the components with the most own time, ranked by `--measure` and `--sort`; a session without stored statistics says `not computed`, never zero. Words come from the one word table (Worse / Warning / Better …), "Worse (not blocking)" when the
198
206
  branch was measured differently from main.
199
207
 
200
208
  ## Output layout
@@ -257,3 +265,24 @@ Memory slopes (Theil-Sen over the cycle index, 3+ cycles of `--mem-window`, defa
257
265
  verdicts, profile-analysis, mcp), so both work from a clean checkout.
258
266
 
259
267
  Only `keepsnappy` and `keepsnappy.yml` / `keepsnappy.yaml` are supported. Configure `KEEPSNAPPY_*` variables. Explicit `--key-env`, `project.key_env`, and `--config` remain arbitrary user-supplied names and paths.
268
+
269
+ ### Source maps from an EAS build
270
+
271
+ In an `eas-build-on-success` hook, run:
272
+
273
+ ```sh
274
+ npx @keepsnappy/agent sourcemaps upload --eas
275
+ ```
276
+
277
+ `--eas` runs only when `EAS_BUILD_PROFILE=e2e`. It uses `EAS_BUILD_GIT_COMMIT_HASH` as the build commit and `EAS_BUILD_PLATFORM` to select the map relative to the working directory:
278
+
279
+ | Platform | Map | Kind |
280
+ | --- | --- | --- |
281
+ | `ios` | `main.jsbundle.packager.map` | `packager` |
282
+ | `android` | `android/app/build/generated/sourcemaps/react/release/index.android.bundle.map` | `hermes` |
283
+
284
+ The iOS packager map preserves the positions used by CPU profiles; Android profiles use the composed Hermes map. The upload flavour defaults to `e2e`.
285
+
286
+ Set `KEEPSNAPPY_KEY` or `KEEPSNAPPY_CI_KEY` and `KEEPSNAPPY_API` or `EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL`. The usual key/API names take precedence over the EAS hook names. Explicit `--key-env`, `--api`/`--ingest-url`, `--build`, `--platform`, `--kind`, `--flavour` and map files override these defaults. An explicit `--key-env` uses only that variable.
287
+
288
+ A missing key, API URL, commit or map, an unsupported platform, or a profile other than `e2e` produces one skip line and exits 0. There is no default API URL in EAS mode. Upload, map parsing and argument failures also exit 0 with a generic failure line so the application build stays successful and error details cannot expose credentials. Ordinary `sourcemaps upload` keeps its existing validation and failure behavior. Source text (`sourcesContent`) is stripped before upload in both modes.
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // packages/appium-driver/src/load.ts
4
- import { isAbsolute, resolve } from "node:path";
4
+ import { isAbsolute, resolve as resolve2 } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
7
7
  // packages/appium-driver/src/run.ts
8
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
9
+ import { join as join2 } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
 
12
12
  // packages/appium-driver/src/browserstack.ts
@@ -314,6 +314,7 @@ class Driver {
314
314
  now;
315
315
  sleep;
316
316
  sessionId = null;
317
+ deviceUnit;
317
318
  context = {};
318
319
  viewport = null;
319
320
  rects = new Map;
@@ -355,6 +356,7 @@ class Driver {
355
356
  async start(capabilities) {
356
357
  const value = await this.cmd("POST", "/session", { capabilities: { alwaysMatch: capabilities } });
357
358
  this.sessionId = value.sessionId;
359
+ this.deviceUnit = [value.capabilities?.udid, value.capabilities?.deviceUDID].find((unit) => typeof unit === "string" && unit.length > 0);
358
360
  return value;
359
361
  }
360
362
  execute(script, arg) {
@@ -641,7 +643,7 @@ var HUB = "https://hub-cloud.browserstack.com/wd/hub";
641
643
  var BS_API = "https://api-cloud.browserstack.com/app-automate";
642
644
  var basicAuth = (user, key) => `Basic ${Buffer.from(`${user}:${key}`).toString("base64")}`;
643
645
  async function resolveApp(app, customId, auth, fetchImpl, platform = IOS) {
644
- if (app.startsWith("bs://"))
646
+ if (app.startsWith("bs://") || app.startsWith("custom_id:"))
645
647
  return app;
646
648
  const headers = { Authorization: auth };
647
649
  if (customId) {
@@ -685,6 +687,29 @@ async function sessionDetail(sessionId, auth, fetchImpl) {
685
687
  return (await res.json()).automation_session ?? {};
686
688
  }
687
689
 
690
+ // packages/appium-driver/src/version.ts
691
+ import { existsSync, readFileSync, statSync } from "node:fs";
692
+ import { dirname, join, relative, resolve } from "node:path";
693
+ import { fileURLToPath } from "node:url";
694
+ var DRIVER_NAME = "appium";
695
+ function driverVersion() {
696
+ const built = "0.1.0-alpha.16";
697
+ if (built)
698
+ return built;
699
+ let dir = dirname(fileURLToPath(import.meta.url));
700
+ for (let i = 0;i < 3; i++, dir = dirname(dir)) {
701
+ const file = join(dir, "package.json");
702
+ if (!existsSync(file))
703
+ continue;
704
+ try {
705
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
706
+ if (pkg.name === "@keepsnappy/appium-driver" && pkg.version)
707
+ return pkg.version;
708
+ } catch {}
709
+ }
710
+ return null;
711
+ }
712
+
688
713
  // packages/appium-driver/src/session.ts
689
714
  var MARKERS_PER_REQUEST = 500;
690
715
  var MARKER_TRIES = 3;
@@ -740,6 +765,9 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
740
765
  }
741
766
  const base = api.replace(/\/+$/, "");
742
767
  const url = `${base}/v1/markers`;
768
+ const version = (opts.driverVersion ?? driverVersion)();
769
+ const reported = opts.reported ?? new Set;
770
+ const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {} } };
743
771
  let sent = 0;
744
772
  for (const [token, list] of byToken) {
745
773
  const trips = [];
@@ -748,7 +776,9 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
748
776
  return carried.length ? { ...body, clock: carried } : body;
749
777
  };
750
778
  const send = async (body) => {
751
- const answer = await postWithRetry(fetchImpl, url, token, withClock(body), sleep, now);
779
+ const answer = await postWithRetry(fetchImpl, url, token, { ...withClock(body), ...reported.has(token) ? {} : driverField }, sleep, now);
780
+ if (answer && answer.status < 300)
781
+ reported.add(token);
752
782
  if (answer?.trip)
753
783
  trips.push(answer.trip);
754
784
  return answer;
@@ -777,6 +807,14 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
777
807
  if (trips.length)
778
808
  await post(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: trips.splice(0) }, now);
779
809
  }
810
+ for (const token of opts.tokens ?? []) {
811
+ if (byToken.has(token) || reported.has(token) || !token || !api)
812
+ continue;
813
+ const answer = await postWithRetry(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: [], ...driverField }, sleep, now);
814
+ reported.add(token);
815
+ if (!answer || answer.status >= 300)
816
+ opts.note?.(`driver report for a run not accepted (${answer?.status ?? "no answer"}); not tried again`);
817
+ }
780
818
  return sent;
781
819
  }
782
820
  var SDK_FLUSH_WORST_CASE_MS = 315000;
@@ -1186,6 +1224,8 @@ class Session {
1186
1224
  }
1187
1225
  async beginRun({ token, args = "", home }) {
1188
1226
  this.token = token;
1227
+ if (token)
1228
+ this.opened.add(token);
1189
1229
  this.pendingReady = null;
1190
1230
  await this.relaunch();
1191
1231
  await this.waitVisible(home, 30000);
@@ -1193,12 +1233,14 @@ class Session {
1193
1233
  await this.driver.sleep(ACTIVATION_SETTLE_MS);
1194
1234
  return alert;
1195
1235
  }
1236
+ opened = new Set;
1237
+ told = new Set;
1196
1238
  lastFlush = null;
1197
1239
  flushSeq = 0;
1198
1240
  async endRun() {
1199
1241
  if (this.markers.some((m) => m.phase === "start"))
1200
1242
  await this.memRead(await this.gc(true));
1201
- await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now() });
1243
+ await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, 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 }) });
1202
1244
  const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1203
1245
  this.lastFlush = evidence;
1204
1246
  const acks = new Set;
@@ -1331,8 +1373,8 @@ function writeAtomic(file, text) {
1331
1373
  renameSync(tmp, file);
1332
1374
  }
1333
1375
  async function captureFailure(driver, out) {
1334
- const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(join(out, "failure-source.xml"), s)), () => null);
1335
- const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(join(out, "failure-screen.png"), Buffer.from(png, "base64"))), () => null);
1376
+ const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(join2(out, "failure-source.xml"), s)), () => null);
1377
+ const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(join2(out, "failure-screen.png"), Buffer.from(png, "base64"))), () => null);
1336
1378
  await Promise.all([source, screen]);
1337
1379
  }
1338
1380
  async function runDriver(argv, module, deps = realRunDeps()) {
@@ -1358,7 +1400,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1358
1400
  const out = String(a.out);
1359
1401
  const auth = basicAuth(user, key);
1360
1402
  mkdirSync(out, { recursive: true });
1361
- const { api, tokens } = parseTokensJs(readFileSync(String(a["tokens-js"]), "utf8"));
1403
+ const { api, tokens } = parseTokensJs(readFileSync2(String(a["tokens-js"]), "utf8"));
1362
1404
  const log = [];
1363
1405
  const driver = new Driver({ hub: HUB, log, headers: { Authorization: auth }, bundleId: module.app.bundleId, platform, fetch: deps.fetch, now: deps.now, sleep: deps.sleep });
1364
1406
  const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
@@ -1378,7 +1420,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1378
1420
  let flushOff = [];
1379
1421
  const iterations = [];
1380
1422
  let terminating = false;
1381
- const logFile = join(out, "driver-log.json");
1423
+ const logFile = join2(out, "driver-log.json");
1382
1424
  const persist = () => {
1383
1425
  const last = t.stepLog.at(-1);
1384
1426
  const body = {
@@ -1419,7 +1461,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1419
1461
  }
1420
1462
  };
1421
1463
  let dashboard = null;
1422
- const writeBuild = (failure) => writeAtomic(join(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
1464
+ const writeBuild = (failure) => writeAtomic(join2(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
1423
1465
  driver: "appium",
1424
1466
  status: failure === null ? "passed" : "failed",
1425
1467
  buildId,
@@ -1587,7 +1629,7 @@ async function runIterations(t, module, ctx, iterations, progress) {
1587
1629
 
1588
1630
  // packages/appium-driver/src/load.ts
1589
1631
  async function loadJourneyModule(path, cwd = process.cwd()) {
1590
- const file = isAbsolute(path) ? path : resolve(cwd, path);
1632
+ const file = isAbsolute(path) ? path : resolve2(cwd, path);
1591
1633
  const mod = await import(pathToFileURL(file).href);
1592
1634
  const m = mod.default ?? mod;
1593
1635
  if (!m.app?.bundleId || !m.app.scheme)