@keepsnappy/agent 0.1.0-alpha.19 → 0.1.0-alpha.21

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
@@ -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
 
@@ -726,13 +726,311 @@ async function sessionDetail(sessionId, auth, fetchImpl) {
726
726
  return (await res.json()).automation_session ?? {};
727
727
  }
728
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
+
729
1027
  // packages/appium-driver/src/version.ts
730
1028
  import { existsSync, readFileSync, statSync } from "node:fs";
731
1029
  import { dirname, join, relative, resolve } from "node:path";
732
1030
  import { fileURLToPath } from "node:url";
733
1031
  var DRIVER_NAME = "appium";
734
1032
  function driverVersion() {
735
- const built = "0.1.0-alpha.19";
1033
+ const built = "0.1.0-alpha.21";
736
1034
  if (built)
737
1035
  return built;
738
1036
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -754,9 +1052,6 @@ var MARKERS_PER_REQUEST = 500;
754
1052
  var MARKER_TRIES = 3;
755
1053
  var HOST_CLOCK_TRIPS = 6;
756
1054
  var RETRY_BASE_MS = 1000;
757
- var FLING_MS = 80;
758
- var FLING_FROM = 0.8;
759
- var FLING_TO = 0.3;
760
1055
  var FLING_TIMEOUT_MS = 30000;
761
1056
  var STALE_REFINDS = 3;
762
1057
  var FRAME_POLL_MS = 50;
@@ -806,7 +1101,10 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
806
1101
  const url = `${base}/v1/markers`;
807
1102
  const version = (opts.driverVersion ?? driverVersion)();
808
1103
  const reported = opts.reported ?? new Set;
809
- 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
+ };
810
1108
  let sent = 0;
811
1109
  for (const [token, list] of byToken) {
812
1110
  const trips = [];
@@ -815,7 +1113,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
815
1113
  return carried.length ? { ...body, clock: carried } : body;
816
1114
  };
817
1115
  const send = async (body) => {
818
- 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);
819
1117
  if (answer && answer.status < 300)
820
1118
  reported.add(token);
821
1119
  if (answer?.trip)
@@ -849,7 +1147,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
849
1147
  for (const token of opts.tokens ?? []) {
850
1148
  if (byToken.has(token) || reported.has(token) || !token || !api)
851
1149
  continue;
852
- 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);
853
1151
  reported.add(token);
854
1152
  if (!answer || answer.status >= 300)
855
1153
  opts.note?.(`driver report for a run not accepted (${answer?.status ?? "no answer"}); not tried again`);
@@ -895,6 +1193,14 @@ var RESCUE_EVERY_MS = 3000;
895
1193
  var noop = async () => {
896
1194
  return;
897
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
+ }
898
1204
 
899
1205
  class Session {
900
1206
  driver;
@@ -1142,16 +1448,21 @@ class Session {
1142
1448
  if (!await this.isVisible(target))
1143
1449
  throw new Error(`${describeSelector(toSelector(target))} not visible after ${times} swipes from ${describeSelector(toSelector(from))}`);
1144
1450
  }
1145
- async scrollUntil(sel, { direction = "down", timeoutMs = 15000, speed = 40 } = {}) {
1451
+ async scrollUntil(sel, { direction = "down", timeoutMs = 15000 } = {}) {
1146
1452
  const s = toSelector(sel);
1147
1453
  const deadline = this.now() + timeoutMs;
1148
- const durationMs = Math.round(1000 * (100 - speed) / 100) + 1;
1454
+ let lookUntil = 0;
1149
1455
  for (;; ) {
1150
1456
  if (await this.driver.findShown(s, true))
1151
1457
  return;
1152
1458
  if (this.now() > deadline)
1153
1459
  throw new Error(`${describeSelector(s)} not scrolled into view within ${timeoutMs} ms`);
1154
- 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;
1155
1466
  }
1156
1467
  }
1157
1468
  async frameOf(sel, el) {
@@ -1186,12 +1497,10 @@ class Session {
1186
1497
  at = now;
1187
1498
  }
1188
1499
  }
1189
- async flingIn(r, toward, durationMs) {
1190
- const [from, to] = toward === "end" ? [FLING_FROM, FLING_TO] : [FLING_TO, FLING_FROM];
1191
- const x = Math.round(r.x + r.width / 2);
1192
- 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 });
1193
1502
  }
1194
- async flingTimes(list, times, toward, { durationMs = FLING_MS, timeoutMs = FLING_TIMEOUT_MS } = {}) {
1503
+ async flingTimes(list, times, toward, { timeoutMs = FLING_TIMEOUT_MS } = {}) {
1195
1504
  if (!Number.isInteger(times) || times < 1)
1196
1505
  throw new Error(`flingTimes needs a whole number of flings >= 1, got ${times}`);
1197
1506
  await this.ready();
@@ -1202,13 +1511,13 @@ class Session {
1202
1511
  try {
1203
1512
  const { rect } = await this.settledFrame(list, timeoutMs);
1204
1513
  for (;flings < times; flings++)
1205
- await this.flingIn(rect, toward, durationMs);
1514
+ await this.flingIn(rect, toward);
1206
1515
  status = 0;
1207
1516
  } finally {
1208
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 });
1209
1518
  }
1210
1519
  }
1211
- 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 } = {}) {
1212
1521
  await this.ready();
1213
1522
  const start = this.now();
1214
1523
  const refindsBefore = this.refinds;
@@ -1220,7 +1529,7 @@ class Session {
1220
1529
  const fling = async () => {
1221
1530
  if (flings > 0)
1222
1531
  at = await this.frameOf(list, at.el);
1223
- await this.flingIn(at.rect, toward, durationMs);
1532
+ await this.flingIn(at.rect, toward);
1224
1533
  flings++;
1225
1534
  };
1226
1535
  while (flings < blind)
@@ -1288,6 +1597,7 @@ class Session {
1288
1597
  this.token = token;
1289
1598
  if (token)
1290
1599
  this.opened.add(token);
1600
+ this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry };
1291
1601
  this.pendingReady = null;
1292
1602
  await this.relaunch();
1293
1603
  await this.waitVisible(home, 30000);
@@ -1297,12 +1607,17 @@ class Session {
1297
1607
  }
1298
1608
  opened = new Set;
1299
1609
  told = new Set;
1610
+ runFrom = null;
1611
+ runDefects = new Map;
1300
1612
  lastFlush = null;
1301
1613
  flushSeq = 0;
1302
1614
  async endRun() {
1303
1615
  if (this.markers.some((m) => m.phase === "start"))
1304
1616
  await this.memRead(await this.gc(true));
1305
- 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 }) });
1306
1621
  const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1307
1622
  this.lastFlush = evidence;
1308
1623
  const acks = new Set;