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

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
@@ -70,7 +70,7 @@ Annotated example: `examples/keepsnappy.yml`. Every setting:
70
70
 
71
71
  Unknown settings and wrong types are errors, all listed together.
72
72
 
73
- Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`) makes "More work" exit `3`. The CLI exits `2` for a configuration problem (bad or missing `keepsnappy.yml`, flags, a glob matching nothing, `--wait` without a branch or key) and `1` for an infrastructure problem (cannot read the device log, an API request failing; results stay in the out dir). Failed runs are marked invalid in `runs.ndjson` / `metrics.csv` and ignored by the statistics. The ending summary reads like:
73
+ Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`) fails the job (exit `3`) only when the verdict computed on that run set is Worse and blocking. Closing a run set never computes its verdict (decision 0031: the operator computes verdicts on demand), so with no computed verdict `--fail-on-worse` checks nothing: the CLI prints `Verdict not computed yet (computed on demand): --fail-on-worse checked nothing` and exits `0`. The CLI exits `2` for a configuration problem (bad or missing `keepsnappy.yml`, unknown or misspelt flags, a glob matching nothing, `--wait` without a branch or key) and `1` for an infrastructure problem (cannot read the device log, an API request failing, an API URL that does not answer as the KeepSnappy API, `--upload` that sent no run: "Blocked"; results stay in the out dir). Failed runs are marked invalid in `runs.ndjson` / `metrics.csv` and ignored by the statistics. The ending summary reads like:
74
74
 
75
75
  ```
76
76
  7/8 runs valid (dropped: 1 test-failed: exit 1)
@@ -137,7 +137,7 @@ runner. It writes:
137
137
  run with data. A measured iteration sends nothing until its end-of-iteration flush, so a run with data completed and
138
138
  is never blamed; a Maestro failure stops the session, so later iterations have no data;
139
139
  3. finishes every run not yet finished (a run without a single batch is reported as "no data", never judged), closes the
140
- set (`--wait`: verdicts inline; else on read) and prints the iterations and the same plain-words verdict as
140
+ set (`--wait`: verdicts inline; else on read). It then prints the iterations and the same plain-words verdict as
141
141
  `keepsnappy run --upload --wait`, plus `$GITHUB_STEP_SUMMARY` and `<out>/keepsnappy-result.json` (`--out`, default the
142
142
  state's directory);
143
143
  4. puts the empty stub back into the tokens file, also when a step above failed. The example workflow also deletes the
@@ -145,13 +145,14 @@ runner. It writes:
145
145
 
146
146
  Running it again is safe: finished runs are left alone, the set is not reprocessed, the same result is printed. A batch
147
147
  that arrives after a run was finished is added by the server and the run re-processed at once. Exit codes: `0` for any
148
- result, `3` with `--fail-on-worse` on "More work", `2` for configuration, `1` when the API fails or nothing could be
148
+ result, `3` with `--fail-on-worse` when the verdict computed on that run set is Worse and blocking (with no computed
149
+ verdict it checks nothing and says so, exit `0`), `2` for configuration, `1` when the API fails or nothing could be
149
150
  judged ("Blocked": no iteration sent any data).
150
151
 
151
- Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close --wait` posts the
152
- result: a pull request only when something got worse (a table of the 3 biggest changes, main vs PR), main only when
153
- something changed (`KEEPSNAPPY_SLACK_ALWAYS=1` also posts "no change"), and a measurement that sent no data always. The
154
- commit's title and author come from `git log` in the checkout. A Slack failure is printed and never changes the exit code.
152
+ Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close` posts a measurement that sent
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
156
 
156
157
  Declared steps on the farm: `maestro/keepsnappy/step.yaml` / `step.js` / `send-markers.js` (copy them into the suite).
157
158
  With a token set, `step.js` only records the marker; `send-markers.js`, run in `end-run.yaml` after each iteration,
@@ -303,6 +303,10 @@ class WdError extends Error {
303
303
  this.wdError = wdError;
304
304
  }
305
305
  }
306
+ var IOS_BOTTOM_INSET_PT = 34;
307
+ var GESTURE_ZONE_MARGIN_PT = 16;
308
+ var ANDROID_NAV_BAR_FALLBACK_DP = 48;
309
+ var GESTURE_ZONE_MARGIN_DP = 16;
306
310
 
307
311
  class Driver {
308
312
  bundleId;
@@ -317,6 +321,8 @@ class Driver {
317
321
  deviceUnit;
318
322
  context = {};
319
323
  viewport = null;
324
+ pxPerDp = null;
325
+ bottomLimit = null;
320
326
  rects = new Map;
321
327
  constructor(o) {
322
328
  this.bundleId = o.bundleId;
@@ -381,6 +387,29 @@ class Driver {
381
387
  this.viewport ??= await this.cmd("GET", "/window/rect");
382
388
  return this.viewport;
383
389
  }
390
+ async density() {
391
+ if (this.pxPerDp === null) {
392
+ const dpi = Number(await this.execute("mobile: getDisplayDensity"));
393
+ if (!(dpi > 0))
394
+ throw new Error(`mobile: getDisplayDensity answered ${dpi}, not a density`);
395
+ this.pxPerDp = dpi / 160;
396
+ }
397
+ return this.pxPerDp;
398
+ }
399
+ async safeBottomY() {
400
+ if (this.bottomLimit === null) {
401
+ const { height } = await this.screen();
402
+ if (this.platform.name === "ios") {
403
+ this.bottomLimit = height - IOS_BOTTOM_INSET_PT - GESTURE_ZONE_MARGIN_PT;
404
+ } else {
405
+ const dp = await this.density();
406
+ const bar = await this.execute("mobile: getSystemBars").then((b) => Number(b?.navigationBar?.height), () => NaN);
407
+ const inset = bar > 0 ? bar : ANDROID_NAV_BAR_FALLBACK_DP * dp;
408
+ this.bottomLimit = Math.round(height - inset - GESTURE_ZONE_MARGIN_DP * dp);
409
+ }
410
+ }
411
+ return this.bottomLimit;
412
+ }
384
413
  async onScreen(el, full = false) {
385
414
  let r;
386
415
  try {
@@ -479,6 +508,7 @@ class Driver {
479
508
  await this.cmd("DELETE", "/actions").catch(() => null);
480
509
  }
481
510
  async drag(from, to, durationMs) {
511
+ to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
482
512
  await this.cmd("POST", "/actions", {
483
513
  actions: [
484
514
  {
@@ -676,7 +706,10 @@ function sessionCaps(i) {
676
706
  sessionName: i.sessionName,
677
707
  ...tunnel,
678
708
  idleTimeout: 300,
679
- appiumVersion: i.appiumVersion
709
+ appiumVersion: i.appiumVersion,
710
+ video: i.capture ?? false,
711
+ deviceLogs: i.capture ?? false,
712
+ networkLogs: false
680
713
  }
681
714
  };
682
715
  }
@@ -693,7 +726,7 @@ import { dirname, join, relative, resolve } from "node:path";
693
726
  import { fileURLToPath } from "node:url";
694
727
  var DRIVER_NAME = "appium";
695
728
  function driverVersion() {
696
- const built = "0.1.0-alpha.16";
729
+ const built = "0.1.0-alpha.18";
697
730
  if (built)
698
731
  return built;
699
732
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -767,7 +800,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
767
800
  const url = `${base}/v1/markers`;
768
801
  const version = (opts.driverVersion ?? driverVersion)();
769
802
  const reported = opts.reported ?? new Set;
770
- const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {} } };
803
+ const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {}, ...opts.capture ? { capture: opts.capture } : {} } };
771
804
  let sent = 0;
772
805
  for (const [token, list] of byToken) {
773
806
  const trips = [];
@@ -824,6 +857,26 @@ var FLUSH_POLL_MS = 500;
824
857
  var FLUSH_RESEND_MS = 90000;
825
858
  var ACTIVATION_SETTLE_MS = 2000;
826
859
  var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
860
+ var flushLoss = (detail) => {
861
+ if (!detail)
862
+ return "no detail";
863
+ const count = (re) => Number(re.exec(detail)?.[1] ?? 0);
864
+ const lost = [];
865
+ const rejected = count(/^rejected \d+: (\d+) batch/);
866
+ if (!detail.startsWith("batches=") && !rejected && !detail.startsWith("lines of the run dropped before sending")) {
867
+ lost.push(`lines not sent (${detail.replace(/ (?:dropped|artifacts)=.*$/, "")})`);
868
+ }
869
+ if (count(/ dropped=(\d+)/))
870
+ lost.push(`lines dropped=${count(/ dropped=(\d+)/)}`);
871
+ if (rejected)
872
+ lost.push(`line batches rejected=${rejected}`);
873
+ const files = /failed=(\d+) pending=(\d+) local=(\d+) rejected=(\d+)/.exec(detail);
874
+ if (files)
875
+ lost.push(`profile files not uploaded=${files.slice(1).reduce((n, x) => n + Number(x), 0)}`);
876
+ if (count(/ lost stream=(\d+)/))
877
+ lost.push(`stream segments lost=${count(/ lost stream=(\d+)/)}`);
878
+ return lost.length ? lost.join(", ") : detail;
879
+ };
827
880
  var FLUSH_UNREADABLE = "not acknowledged (unreadable answer)";
828
881
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
829
882
  var UNREADABLE_POLLS = 3;
@@ -849,9 +902,11 @@ class Session {
849
902
  readyWaits = [];
850
903
  actionGc;
851
904
  endGc;
905
+ capture;
852
906
  gcN = 0;
853
907
  stepDepth = 0;
854
908
  stepLog = [];
909
+ defects = { gestureRetry: 0, items: [] };
855
910
  onStepChange = null;
856
911
  refinds = 0;
857
912
  get staleRefinds() {
@@ -864,6 +919,7 @@ class Session {
864
919
  this.api = o.api ?? "";
865
920
  this.actionGc = o.actionGc ? "step" : "off";
866
921
  this.endGc = o.endGc ?? END_GC_DEFAULT;
922
+ this.capture = o.capture;
867
923
  }
868
924
  async gc(atEnd = false) {
869
925
  if (this.actionGc !== "step" && !(atEnd && this.endGc && this.actionGc === "off"))
@@ -1240,7 +1296,7 @@ class Session {
1240
1296
  async endRun() {
1241
1297
  if (this.markers.some((m) => m.phase === "start"))
1242
1298
  await this.memRead(await this.gc(true));
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 }) });
1299
+ await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, capture: this.capture, 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 }) });
1244
1300
  const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1245
1301
  this.lastFlush = evidence;
1246
1302
  const acks = new Set;
@@ -1357,7 +1413,8 @@ var DRIVER_OPTIONS = {
1357
1413
  out: { type: "string", default: "farm-run" },
1358
1414
  platform: { type: "string", default: "ios" },
1359
1415
  "action-gc": { type: "string", default: "off" },
1360
- "end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" }
1416
+ "end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" },
1417
+ capture: { type: "string", default: "off" }
1361
1418
  };
1362
1419
  function parseDriverArgs(argv, module) {
1363
1420
  const values = parseArgs({ args: [...argv], options: { ...module.options ?? {}, ...DRIVER_OPTIONS } }).values;
@@ -1397,13 +1454,16 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1397
1454
  throw new Error("--action-gc must be on or off");
1398
1455
  if (a["end-gc"] !== "on" && a["end-gc"] !== "off")
1399
1456
  throw new Error("--end-gc must be on or off");
1457
+ if (a.capture !== "on" && a.capture !== "off")
1458
+ throw new Error("--capture must be on or off");
1459
+ const capture = a.capture === "on";
1400
1460
  const out = String(a.out);
1401
1461
  const auth = basicAuth(user, key);
1402
1462
  mkdirSync(out, { recursive: true });
1403
1463
  const { api, tokens } = parseTokensJs(readFileSync2(String(a["tokens-js"]), "utf8"));
1404
1464
  const log = [];
1405
1465
  const driver = new Driver({ hub: HUB, log, headers: { Authorization: auth }, bundleId: module.app.bundleId, platform, fetch: deps.fetch, now: deps.now, sleep: deps.sleep });
1406
- const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
1466
+ const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on", capture: capture ? "on" : "off" });
1407
1467
  const sessionName = module.sessionName ?? "keepsnappy (appium)";
1408
1468
  const device = String(a.device);
1409
1469
  const ctx = { fetch: deps.fetch, tokens, journeys, repeat, args: a };
@@ -1427,6 +1487,8 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1427
1487
  driver: "appium",
1428
1488
  status,
1429
1489
  device,
1490
+ unit: driver.deviceUnit ?? null,
1491
+ capture,
1430
1492
  appUrl,
1431
1493
  session: driver.sessionId,
1432
1494
  buildId,
@@ -1444,6 +1506,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1444
1506
  flushError,
1445
1507
  flushOff,
1446
1508
  finalFlush,
1509
+ defects: t.defects,
1447
1510
  steps: t.stepLog,
1448
1511
  iterations,
1449
1512
  commands: log
@@ -1531,13 +1594,14 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1531
1594
  try {
1532
1595
  appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
1533
1596
  deps.print(`app: ${appUrl}`);
1534
- const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"] });
1597
+ const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"], capture });
1535
1598
  startedAt = deps.now();
1536
1599
  try {
1537
1600
  await driver.start(caps);
1538
1601
  deps.print(`session ${driver.sessionId}`);
1539
1602
  progress();
1540
1603
  await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
1604
+ await driver.safeBottomY().catch(() => null);
1541
1605
  await module.setup?.(t, ctx);
1542
1606
  await runIterations(t, module, ctx, iterations, progress);
1543
1607
  } catch (e) {
@@ -1569,8 +1633,13 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1569
1633
  flushOff = flushedAs(FLUSH_OFF, true);
1570
1634
  if (flushNotAcknowledged.length)
1571
1635
  deps.error(`::error::flush not acknowledged (no answer after the re-send, an unreadable answer, or a failed end-run): ${flushNotAcknowledged.join(", ")}`);
1572
- if (flushError.length)
1573
- deps.error(`::error::flush answered error (data not uploaded): ${flushError.join(", ")}`);
1636
+ if (flushError.length) {
1637
+ const losses = [
1638
+ ...iterations.filter((r) => r.flushed === FLUSH_ERROR).map((r) => `${r.journey} #${r.iteration} (${flushLoss(r.flush?.detail)})`),
1639
+ ...finalFlush?.flushed === FLUSH_ERROR ? [`final flush (${flushLoss(finalFlush.flush?.detail)})`] : []
1640
+ ];
1641
+ deps.error(`::error::flush answered error, data lost: ${losses.join(", ")}`);
1642
+ }
1574
1643
  if (flushOff.length)
1575
1644
  deps.error(`::error::flush answered off in a run with a token (upload not configured, nothing uploaded): ${flushOff.join(", ")}`);
1576
1645
  endedAt = deps.now();