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

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`) 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:
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", or a local run with no valid run; results stay in the out dir). For a local run with no valid run, it prints `No valid run was recorded` with the invalid reasons when known. 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)
@@ -105,7 +105,7 @@ keepsnappy farm close --state farm-run/keepsnappy-farm.json --bs-build farm-run/
105
105
 
106
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
- 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`.
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`. `--plant JSON` records what the build plants against main (a truth runner's planted variant) as the run set's `meta.plant`: a list of `{ place, kind, factor, unit }`, `factor` > 0 in `unit` `budget` (multiples of the frame budget) or `MB`; the quality report shows it as the session's dose.
109
109
 
110
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
111
 
@@ -507,23 +507,29 @@ class Driver {
507
507
  });
508
508
  await this.cmd("DELETE", "/actions").catch(() => null);
509
509
  }
510
- async drag(from, to, durationMs) {
510
+ async drag(from, to, durationMs, note) {
511
511
  to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
512
- await this.cmd("POST", "/actions", {
513
- actions: [
514
- {
515
- type: "pointer",
516
- id: "finger1",
517
- parameters: { pointerType: "touch" },
518
- actions: [
519
- { type: "pointerMove", duration: 0, x: from.x, y: from.y },
520
- { type: "pointerDown", button: 0 },
521
- { type: "pointerMove", duration: durationMs, x: to.x, y: to.y },
522
- { type: "pointerUp", button: 0 }
523
- ]
524
- }
525
- ]
526
- });
512
+ const logged = this.log.length;
513
+ try {
514
+ await this.cmd("POST", "/actions", {
515
+ actions: [
516
+ {
517
+ type: "pointer",
518
+ id: "finger1",
519
+ parameters: { pointerType: "touch" },
520
+ actions: [
521
+ { type: "pointerMove", duration: 0, x: from.x, y: from.y },
522
+ { type: "pointerDown", button: 0 },
523
+ { type: "pointerMove", duration: durationMs, x: to.x, y: to.y },
524
+ { type: "pointerUp", button: 0 }
525
+ ]
526
+ }
527
+ ]
528
+ });
529
+ } finally {
530
+ if (note && this.log[logged])
531
+ Object.assign(this.log[logged], note);
532
+ }
527
533
  await this.cmd("DELETE", "/actions").catch(() => null);
528
534
  }
529
535
  async relaunch(bundleId = this.bundleId) {
@@ -720,13 +726,311 @@ async function sessionDetail(sessionId, auth, fetchImpl) {
720
726
  return (await res.json()).automation_session ?? {};
721
727
  }
722
728
 
729
+ // packages/appium-driver/src/gestures.ts
730
+ var FLING = {
731
+ end: [0.85, 0.25],
732
+ top: [0.3, 0.9],
733
+ minTravel: 250,
734
+ contactMs: 150,
735
+ startMargin: 0.1
736
+ };
737
+ function flingStroke(r, toward, unit, safeBottom, start) {
738
+ const [f, t] = FLING[toward];
739
+ const travel = Math.round(Math.max(Math.abs(t - f) * r.height, FLING.minTravel * unit));
740
+ const step = toward === "end" ? -travel : travel;
741
+ const clamp = (y) => Math.min(Math.max(y, 0), safeBottom);
742
+ let from = Math.round(start ?? r.y + r.height * f);
743
+ const below = Math.max(from, from + step) - safeBottom;
744
+ const above = -Math.min(from, from + step);
745
+ if (below > 0)
746
+ from = Math.max(from - below, Math.min(from, Math.round(r.y + r.height * FLING.startMargin)));
747
+ else if (above > 0)
748
+ from = Math.min(from + above, Math.max(from, Math.round(r.y + r.height * (1 - FLING.startMargin))));
749
+ from = clamp(from);
750
+ const to = clamp(from + step);
751
+ return { from, to, travel, clamped: Math.abs(to - from) < travel };
752
+ }
753
+
754
+ // packages/appium-driver/src/scroll.ts
755
+ var COAST_POLL_MS = 300;
756
+ var COAST_MAX_MS = 3000;
757
+ var FLING_UNMOVED_MS = 4500;
758
+
759
+ class GestureNotTaken extends Error {
760
+ name = "GestureNotTaken";
761
+ }
762
+ async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false, coastMaxMs = COAST_MAX_MS } = {}) {
763
+ if (spec.screen) {
764
+ if (t.driver.platform.name === "ios" && spec.row !== undefined)
765
+ await drawnRowDrag(t, spec, toward);
766
+ else {
767
+ await t.ready();
768
+ await listGesture(t, (await t.frameOf(spec.list, await t.waitVisible(spec.list))).rect, toward);
769
+ }
770
+ return { outcome: "moved", retried: false, sentAt: t.now() };
771
+ }
772
+ if (at)
773
+ return { ...await listGesture(t, at, toward), retried: false };
774
+ const c = cache ?? {};
775
+ const checked = mustMove && spec.row !== undefined;
776
+ const ios = t.driver.platform.name === "ios";
777
+ const held = checked && ios ? hold ?? (c.hold ??= {}) : hold;
778
+ const send = async (first) => {
779
+ c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
780
+ if (first && held)
781
+ await holdBeforeScroll(t, spec.row, handle(spec, c), spec.field, held);
782
+ const before = checked && ios ? await heldFrame(t, held) : null;
783
+ const g = await listGesture(t, c.at.rect, toward);
784
+ const after = before ? await heldFrame(t, held) : null;
785
+ const suspect = ios ? before !== null && before === after : g.outcome === "unmoved";
786
+ return { ...g, suspect };
787
+ };
788
+ const first = await send(true);
789
+ if (!checked)
790
+ return { outcome: first.outcome, answer: first.answer, answerMs: first.answerMs, sentAt: first.sentAt, retried: false };
791
+ if (!first.suspect)
792
+ return { outcome: "moved", answer: first.answer, answerMs: first.answerMs, sentAt: first.sentAt, retried: false };
793
+ await waitCoasted(t, spec.row, handle(spec, c), spec.field, coastMaxMs, held);
794
+ const before = await rowsInList(t, spec, c);
795
+ if (!room(t, before, toward))
796
+ return { outcome: "at-edge", answer: first.answer, answerMs: first.answerMs, sentAt: first.sentAt, retried: false };
797
+ const label = listLabel(spec.list);
798
+ const defect = {
799
+ ...t.driver.context,
800
+ step: t.driver.context.step ?? null,
801
+ t: t.now(),
802
+ list: label,
803
+ toward,
804
+ reason: "unmoved",
805
+ before: before.ids,
806
+ after: null,
807
+ answer: ios ? null : first.answer,
808
+ answerMs: ios ? null : first.answerMs ?? null
809
+ };
810
+ t.defects.gestureRetry++;
811
+ t.defects.items.push(defect);
812
+ const again = await send(false);
813
+ await waitCoasted(t, spec.row, handle(spec, c), spec.field, coastMaxMs, held);
814
+ const after = await rowsInList(t, spec, c);
815
+ defect.after = after.ids;
816
+ if (after.ids.join(`
817
+ `) === before.ids.join(`
818
+ `))
819
+ throw new GestureNotTaken(`list ${label} did not move after 2 identical gestures (the device took no scroll)`);
820
+ return { outcome: "moved", answer: again.answer, answerMs: again.answerMs, sentAt: again.sentAt, retried: true };
821
+ }
822
+ var handle = (spec, c) => spec.scoped ?? true ? { sel: spec.list, el: c.at?.el } : undefined;
823
+ var listLabel = (list) => {
824
+ const s = toSelector(list);
825
+ return s.id ?? s.text ?? describeSelector(s);
826
+ };
827
+ async function heldFrame(t, hold) {
828
+ if (!hold.el)
829
+ return null;
830
+ try {
831
+ const r = await t.driver.cmd("GET", `/element/${hold.el}/rect`);
832
+ return `${r.x},${r.y},${r.width},${r.height}`;
833
+ } catch {
834
+ hold.el = null;
835
+ return null;
836
+ }
837
+ }
838
+ async function rowsInList(t, spec, c) {
839
+ c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
840
+ const list = c.at.rect;
841
+ const { childOf: _list, ...sel } = toSelector(spec.row);
842
+ const all = t.driver.platform.locators(sel);
843
+ const android = t.driver.platform.name === "android";
844
+ const call = spec.field === "description" ? /descriptionMatches/ : /textMatches/;
845
+ const own = android && spec.field ? all.filter((l) => call.test(l.value)) : all;
846
+ const attr = sel.text !== undefined ? android ? spec.field === "description" ? "content-desc" : "text" : "label" : android ? "resource-id" : "name";
847
+ const frames = [];
848
+ const inside = [];
849
+ const parent = spec.scoped ?? true ? c.at.el : undefined;
850
+ for (const { using, value } of own.length ? own : all) {
851
+ for (const el of await t.driver.findAll(using, value, parent)) {
852
+ let r;
853
+ try {
854
+ r = await t.driver.cmd("GET", `/element/${el}/rect`);
855
+ } catch {
856
+ continue;
857
+ }
858
+ if (r.width <= 0 || r.height <= 0)
859
+ continue;
860
+ frames.push(r);
861
+ if (r.y < list.y + list.height && r.y + r.height > list.y)
862
+ inside.push({ y: r.y, el });
863
+ }
864
+ if (frames.length)
865
+ break;
866
+ }
867
+ const ids = [];
868
+ for (const { el } of inside.sort((a, b) => a.y - b.y))
869
+ ids.push(String(await t.driver.attribute(el, attr).catch(() => null) ?? ""));
870
+ return { ids, frames, list };
871
+ }
872
+ function room(t, { frames, list }, toward) {
873
+ if (frames.length === 0)
874
+ return false;
875
+ const android = t.driver.platform.name === "android";
876
+ const bottom = list.y + list.height;
877
+ if (toward === "end") {
878
+ const reach = Math.max(...frames.map((r) => r.y + r.height));
879
+ return android ? reach >= bottom - 1 : reach > bottom;
880
+ }
881
+ const reach = Math.min(...frames.map((r) => r.y));
882
+ return android ? reach <= list.y + 1 : reach < list.y;
883
+ }
884
+ async function listGesture(t, r, toward) {
885
+ await t.ready();
886
+ const android = t.driver.platform.name === "android";
887
+ const safe = await t.driver.safeBottomY();
888
+ const s = flingStroke(r, toward, android ? await t.driver.density() : 1, safe);
889
+ const note = s.clamped ? { clamped: true, travel: Math.abs(s.to - s.from) } : undefined;
890
+ if (android) {
891
+ const logged = t.driver.log.length;
892
+ const sent = t.now();
893
+ const answer = await t.driver.execute("mobile: flingGesture", {
894
+ left: r.x,
895
+ top: Math.min(s.from, s.to),
896
+ width: r.width,
897
+ height: Math.abs(s.to - s.from),
898
+ direction: toward === "end" ? "down" : "up",
899
+ speed: Math.round(1000 * Math.abs(s.to - s.from) / FLING.contactMs)
900
+ });
901
+ const answerMs = t.now() - sent;
902
+ if (note && t.driver.log[logged])
903
+ Object.assign(t.driver.log[logged], note);
904
+ return { outcome: answer === false && answerMs >= FLING_UNMOVED_MS ? "unmoved" : "moved", answer, answerMs, sentAt: t.now() };
905
+ }
906
+ await drag(t, Math.round(r.x + r.width / 2), s, note);
907
+ return { outcome: "moved", sentAt: t.now() };
908
+ }
909
+ var drag = (t, x, s, note) => t.driver.drag({ x, y: s.from }, { x, y: s.to }, FLING.contactMs, note);
910
+ async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn row to scroll from" }, toward) {
911
+ await t.ready();
912
+ const { using, value } = t.driver.platform.locators(toSelector(row))[0];
913
+ let start = null;
914
+ const drawnAbove = [];
915
+ if (toward === "top") {
916
+ const limit = above ? (await t.driver.cmd("GET", `/element/${await t.waitVisible(toSelector(above))}/rect`)).y : Infinity;
917
+ let lowest = null;
918
+ for (const el of await t.driver.findAll(using, value)) {
919
+ if (await t.driver.cmd("GET", `/element/${el}/attribute/visible`) !== "true")
920
+ continue;
921
+ const r = await t.driver.cmd("GET", `/element/${el}/rect`);
922
+ if (!lowest || r.y > lowest.y)
923
+ lowest = r;
924
+ if (r.y + r.height > limit)
925
+ continue;
926
+ drawnAbove.push(r);
927
+ if (!start || r.y > start.y)
928
+ start = r;
929
+ }
930
+ start ??= lowest;
931
+ } else {
932
+ for (const el of await t.driver.findAll(using, `${value} AND visible == 1`)) {
933
+ const r = await t.driver.cmd("GET", `/element/${el}/rect`);
934
+ if (!start || r.y > start.y)
935
+ start = r;
936
+ }
937
+ }
938
+ if (!start)
939
+ throw new Error(missingMessage);
940
+ const x = Math.round(start.x + start.width / 2);
941
+ const listEl = await t.driver.findShown(toSelector(list));
942
+ const l = listEl ? await t.driver.cmd("GET", `/element/${listEl}/rect`) : await t.driver.screen();
943
+ let y = Math.round(start.y + start.height / 2);
944
+ if (toward === "end")
945
+ y = Math.min(Math.max(y, l.y + 1), l.y + l.height - 1);
946
+ const safe = await t.driver.safeBottomY();
947
+ let s = flingStroke(l, toward, 1, safe, y);
948
+ if (toward === "top" && s.from !== y && !(drawnAbove.some((r) => r.y <= s.from && s.from < r.y + r.height) && s.from > l.y && s.from < l.y + l.height)) {
949
+ s = { ...s, from: y, to: Math.min(y + s.travel, safe), clamped: true };
950
+ }
951
+ await drag(t, x, s, s.clamped ? { clamped: true, travel: Math.abs(s.to - s.from) } : undefined);
952
+ }
953
+ async function firstDrawn(t, row, listEl, field, scopedOrField) {
954
+ if (!scopedOrField) {
955
+ return t.driver.findShown(toSelector(row));
956
+ }
957
+ const { childOf: _list, ...inList } = toSelector(row);
958
+ const all = t.driver.platform.locators(inList);
959
+ const call = field === "text" ? /textMatches/ : /descriptionMatches/;
960
+ const own = t.driver.platform.name === "android" && field ? all.filter((l) => call.test(l.value)) : all;
961
+ for (const { using, value } of own.length ? own : all) {
962
+ const drawn = t.driver.platform.name === "ios" && using === "-ios predicate string" ? `${value} AND visible == 1` : value;
963
+ const found = await t.driver.find(using, drawn, listEl);
964
+ if (found) {
965
+ return found;
966
+ }
967
+ }
968
+ return null;
969
+ }
970
+ async function holdBeforeScroll(t, row, list, field, hold) {
971
+ if (!hold || hold.el) {
972
+ return;
973
+ }
974
+ try {
975
+ hold.el = await firstDrawn(t, row, list?.el, field, Boolean(list || field));
976
+ } catch {
977
+ hold.el = null;
978
+ }
979
+ }
980
+ async function waitCoasted(t, row, list, field, maxMs = COAST_MAX_MS, hold) {
981
+ const until = t.now() + maxMs;
982
+ let el = list?.el;
983
+ let refinds = 0;
984
+ const read = async () => {
985
+ if (hold?.el) {
986
+ try {
987
+ return await t.driver.cmd("GET", `/element/${hold.el}/rect`);
988
+ } catch {
989
+ hold.el = null;
990
+ }
991
+ }
992
+ const found = await firstDrawn(t, row, el, field, Boolean(list || field));
993
+ if (found && hold) {
994
+ hold.el = found;
995
+ }
996
+ return found ? t.driver.cmd("GET", `/element/${found}/rect`) : null;
997
+ };
998
+ let last = null;
999
+ let reads = 0;
1000
+ for (;; ) {
1001
+ let r = null;
1002
+ reads++;
1003
+ try {
1004
+ r = await read();
1005
+ } catch {
1006
+ r = null;
1007
+ }
1008
+ if (r === null && list && refinds < 2) {
1009
+ refinds++;
1010
+ el = await t.waitVisible(list.sel);
1011
+ continue;
1012
+ }
1013
+ const frame = r && `${r.x},${r.y},${r.width},${r.height}`;
1014
+ if (frame && frame === last)
1015
+ return true;
1016
+ if (t.now() >= until && reads >= 2)
1017
+ return false;
1018
+ last = frame;
1019
+ await t.driver.sleep(COAST_POLL_MS);
1020
+ }
1021
+ }
1022
+
1023
+ // packages/appium-driver/src/lists.ts
1024
+ var LOOK_MS = 1000;
1025
+ var LOOK_POLL_MS = 100;
1026
+
723
1027
  // packages/appium-driver/src/version.ts
724
1028
  import { existsSync, readFileSync, statSync } from "node:fs";
725
1029
  import { dirname, join, relative, resolve } from "node:path";
726
1030
  import { fileURLToPath } from "node:url";
727
1031
  var DRIVER_NAME = "appium";
728
1032
  function driverVersion() {
729
- const built = "0.1.0-alpha.18";
1033
+ const built = "0.1.0-alpha.20";
730
1034
  if (built)
731
1035
  return built;
732
1036
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -748,9 +1052,6 @@ var MARKERS_PER_REQUEST = 500;
748
1052
  var MARKER_TRIES = 3;
749
1053
  var HOST_CLOCK_TRIPS = 6;
750
1054
  var RETRY_BASE_MS = 1000;
751
- var FLING_MS = 80;
752
- var FLING_FROM = 0.8;
753
- var FLING_TO = 0.3;
754
1055
  var FLING_TIMEOUT_MS = 30000;
755
1056
  var STALE_REFINDS = 3;
756
1057
  var FRAME_POLL_MS = 50;
@@ -800,7 +1101,10 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
800
1101
  const url = `${base}/v1/markers`;
801
1102
  const version = (opts.driverVersion ?? driverVersion)();
802
1103
  const reported = opts.reported ?? new Set;
803
- const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {}, ...opts.capture ? { capture: opts.capture } : {} } };
1104
+ const driverField = (token) => {
1105
+ const defects = opts.defects?.(token);
1106
+ return { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {}, ...opts.capture ? { capture: opts.capture } : {}, ...defects ? { defects } : {} } };
1107
+ };
804
1108
  let sent = 0;
805
1109
  for (const [token, list] of byToken) {
806
1110
  const trips = [];
@@ -809,7 +1113,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
809
1113
  return carried.length ? { ...body, clock: carried } : body;
810
1114
  };
811
1115
  const send = async (body) => {
812
- const answer = await postWithRetry(fetchImpl, url, token, { ...withClock(body), ...reported.has(token) ? {} : driverField }, sleep, now);
1116
+ const answer = await postWithRetry(fetchImpl, url, token, { ...withClock(body), ...reported.has(token) ? {} : driverField(token) }, sleep, now);
813
1117
  if (answer && answer.status < 300)
814
1118
  reported.add(token);
815
1119
  if (answer?.trip)
@@ -843,7 +1147,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
843
1147
  for (const token of opts.tokens ?? []) {
844
1148
  if (byToken.has(token) || reported.has(token) || !token || !api)
845
1149
  continue;
846
- const answer = await postWithRetry(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: [], ...driverField }, sleep, now);
1150
+ const answer = await postWithRetry(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: [], ...driverField(token) }, sleep, now);
847
1151
  reported.add(token);
848
1152
  if (!answer || answer.status >= 300)
849
1153
  opts.note?.(`driver report for a run not accepted (${answer?.status ?? "no answer"}); not tried again`);
@@ -889,6 +1193,14 @@ var RESCUE_EVERY_MS = 3000;
889
1193
  var noop = async () => {
890
1194
  return;
891
1195
  };
1196
+ var RETRY_COMMAND = /-retry$/;
1197
+ function harnessDefects(commands, gestureRetries) {
1198
+ const out = { gestureRetry: gestureRetries };
1199
+ for (const c of commands)
1200
+ if (RETRY_COMMAND.test(c.cmd))
1201
+ out[c.cmd] = (out[c.cmd] ?? 0) + 1;
1202
+ return out;
1203
+ }
892
1204
 
893
1205
  class Session {
894
1206
  driver;
@@ -1136,16 +1448,21 @@ class Session {
1136
1448
  if (!await this.isVisible(target))
1137
1449
  throw new Error(`${describeSelector(toSelector(target))} not visible after ${times} swipes from ${describeSelector(toSelector(from))}`);
1138
1450
  }
1139
- async scrollUntil(sel, { direction = "down", timeoutMs = 15000, speed = 40 } = {}) {
1451
+ async scrollUntil(sel, { direction = "down", timeoutMs = 15000 } = {}) {
1140
1452
  const s = toSelector(sel);
1141
1453
  const deadline = this.now() + timeoutMs;
1142
- const durationMs = Math.round(1000 * (100 - speed) / 100) + 1;
1454
+ let lookUntil = 0;
1143
1455
  for (;; ) {
1144
1456
  if (await this.driver.findShown(s, true))
1145
1457
  return;
1146
1458
  if (this.now() > deadline)
1147
1459
  throw new Error(`${describeSelector(s)} not scrolled into view within ${timeoutMs} ms`);
1148
- await this.swipe([50, 50], direction === "down" ? [50, 10] : [50, 90], durationMs);
1460
+ if (this.now() < lookUntil) {
1461
+ await this.driver.sleep(LOOK_POLL_MS);
1462
+ continue;
1463
+ }
1464
+ await scrollList(this, {}, direction === "down" ? "end" : "top", { at: await this.driver.screen() });
1465
+ lookUntil = this.now() + LOOK_MS;
1149
1466
  }
1150
1467
  }
1151
1468
  async frameOf(sel, el) {
@@ -1180,12 +1497,10 @@ class Session {
1180
1497
  at = now;
1181
1498
  }
1182
1499
  }
1183
- async flingIn(r, toward, durationMs) {
1184
- const [from, to] = toward === "end" ? [FLING_FROM, FLING_TO] : [FLING_TO, FLING_FROM];
1185
- const x = Math.round(r.x + r.width / 2);
1186
- await this.driver.drag({ x, y: Math.round(r.y + r.height * from) }, { x, y: Math.round(r.y + r.height * to) }, durationMs);
1500
+ async flingIn(r, toward) {
1501
+ await scrollList(this, {}, toward, { at: r });
1187
1502
  }
1188
- async flingTimes(list, times, toward, { durationMs = FLING_MS, timeoutMs = FLING_TIMEOUT_MS } = {}) {
1503
+ async flingTimes(list, times, toward, { timeoutMs = FLING_TIMEOUT_MS } = {}) {
1189
1504
  if (!Number.isInteger(times) || times < 1)
1190
1505
  throw new Error(`flingTimes needs a whole number of flings >= 1, got ${times}`);
1191
1506
  await this.ready();
@@ -1196,13 +1511,13 @@ class Session {
1196
1511
  try {
1197
1512
  const { rect } = await this.settledFrame(list, timeoutMs);
1198
1513
  for (;flings < times; flings++)
1199
- await this.flingIn(rect, toward, durationMs);
1514
+ await this.flingIn(rect, toward);
1200
1515
  status = 0;
1201
1516
  } finally {
1202
1517
  this.driver.log.push({ ...this.driver.context, cmd: `fling-${toward}-times`, t: this.now(), ms: this.now() - start, status, flings, refinds: this.refinds - refindsBefore });
1203
1518
  }
1204
1519
  }
1205
- async flingUntil(list, target, toward, { blind = 0, durationMs = FLING_MS, timeoutMs = FLING_TIMEOUT_MS } = {}) {
1520
+ async flingUntil(list, target, toward, { blind = 0, timeoutMs = FLING_TIMEOUT_MS } = {}) {
1206
1521
  await this.ready();
1207
1522
  const start = this.now();
1208
1523
  const refindsBefore = this.refinds;
@@ -1214,7 +1529,7 @@ class Session {
1214
1529
  const fling = async () => {
1215
1530
  if (flings > 0)
1216
1531
  at = await this.frameOf(list, at.el);
1217
- await this.flingIn(at.rect, toward, durationMs);
1532
+ await this.flingIn(at.rect, toward);
1218
1533
  flings++;
1219
1534
  };
1220
1535
  while (flings < blind)
@@ -1282,6 +1597,7 @@ class Session {
1282
1597
  this.token = token;
1283
1598
  if (token)
1284
1599
  this.opened.add(token);
1600
+ this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry };
1285
1601
  this.pendingReady = null;
1286
1602
  await this.relaunch();
1287
1603
  await this.waitVisible(home, 30000);
@@ -1291,12 +1607,17 @@ class Session {
1291
1607
  }
1292
1608
  opened = new Set;
1293
1609
  told = new Set;
1610
+ runFrom = null;
1611
+ runDefects = new Map;
1294
1612
  lastFlush = null;
1295
1613
  flushSeq = 0;
1296
1614
  async endRun() {
1297
1615
  if (this.markers.some((m) => m.phase === "start"))
1298
1616
  await this.memRead(await this.gc(true));
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 }) });
1617
+ 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));
1619
+ this.runFrom = null;
1620
+ 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 }) });
1300
1621
  const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1301
1622
  this.lastFlush = evidence;
1302
1623
  const acks = new Set;