@keepsnappy/agent 0.1.0-alpha.13 → 0.1.0-alpha.14

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.
@@ -5,7 +5,7 @@ import { isAbsolute, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
7
7
  // packages/appium-driver/src/run.ts
8
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
 
@@ -32,8 +32,9 @@ function linkArgs(args = "") {
32
32
  return [...fields].map(([k, v]) => `${k}=${v}`).join("&");
33
33
  }
34
34
  var activationLink = (scheme, token, args = "") => `${route(scheme)}?activate=1&${linkArgs(args)}&runToken=${token}`;
35
- var flushLink = (scheme) => `${route(scheme)}?flush=1&activate=0`;
35
+ var flushLink = (scheme, ack) => `${route(scheme)}?flush=1&activate=0${ack === undefined ? "" : `&ack=${ack}`}`;
36
36
  var gcLink = (scheme, n) => `${route(scheme)}?cmd=gc&n=${n}`;
37
+ var memReadLink = (scheme, n) => `${route(scheme)}?cmd=memread&n=${n}`;
37
38
  async function acceptAlert(driver, labels, waitMs) {
38
39
  const deadline = driver.now() + waitMs;
39
40
  for (;; ) {
@@ -510,9 +511,10 @@ var FLUSH_ID = "keepsnappy.flushed";
510
511
  var ACK_ID = "keepsnappy.ack";
511
512
  var GC_ANSWER_WAIT_MS = 1e4;
512
513
  var GC_POLL_MS = 100;
513
- function parseBroadcastAnswer(out, n) {
514
+ var MEMREAD_ANSWER_WAIT_MS = 3000;
515
+ function parseBroadcastAnswer(out, n, cmd = "gc") {
514
516
  const data = /data="([^"]*)"/.exec(String(out ?? ""))?.[1];
515
- return data !== undefined && data.startsWith(`gc:${n} `) ? data : null;
517
+ return data !== undefined && data.startsWith(`${cmd}:${n} `) ? data : null;
516
518
  }
517
519
  var NO_WAIT_MS = 0;
518
520
  var NEW_COMMAND_TIMEOUT_S = 300;
@@ -534,25 +536,27 @@ var IOS = {
534
536
  appArg: (bundleId) => ({ bundleId }),
535
537
  openLink: (driver, url, state) => openLink(driver, url, state),
536
538
  acceptAlert: (driver, labels, waitMs) => acceptAlert(driver, labels, waitMs),
537
- flush: { lookup: { using: "accessibility id", value: FLUSH_ID }, statusAttribute: "label" },
539
+ flush: { lookup: { using: "accessibility id", value: FLUSH_ID }, statusAttribute: "label", detailAttribute: "value" },
538
540
  uploadFields: { ios_keychain_support: "true" },
539
- async gcCommand(driver, n, scheme, state) {
540
- await openLink(driver, gcLink(scheme, n), state);
541
- const want = `type == "XCUIElementTypeStaticText" AND name == "${ACK_ID}" AND label BEGINSWITH "gc:${n} "`;
542
- const deadline = driver.now() + GC_ANSWER_WAIT_MS;
543
- for (;; ) {
544
- const el = await driver.find("-ios predicate string", want);
545
- if (el)
546
- return driver.cmd("GET", `/element/${el}/attribute/label`).then(String).catch(() => {
547
- driver.log.push({ ...driver.context, cmd: "gc-ack-label", t: driver.now(), ms: 0, status: 1 });
548
- return `gc:${n} ok`;
549
- });
550
- if (driver.now() > deadline)
551
- return null;
552
- await driver.sleep(GC_POLL_MS);
553
- }
554
- }
541
+ gcCommand: (driver, n, scheme, state) => iosCommand(driver, gcLink(scheme, n), "gc", n, state, GC_ANSWER_WAIT_MS),
542
+ memReadCommand: (driver, n, scheme, state) => iosCommand(driver, memReadLink(scheme, n), "memread", n, state, MEMREAD_ANSWER_WAIT_MS)
555
543
  };
544
+ async function iosCommand(driver, link, cmd, n, state, waitMs) {
545
+ await openLink(driver, link, state);
546
+ const want = `type == "XCUIElementTypeStaticText" AND name == "${ACK_ID}" AND label BEGINSWITH "${cmd}:${n} "`;
547
+ const deadline = driver.now() + waitMs;
548
+ for (;; ) {
549
+ const el = await driver.find("-ios predicate string", want);
550
+ if (el)
551
+ return driver.cmd("GET", `/element/${el}/attribute/label`).then(String).catch(() => {
552
+ driver.log.push({ ...driver.context, cmd: `${cmd}-ack-label`, t: driver.now(), ms: 0, status: 1 });
553
+ return `${cmd}:${n} ok`;
554
+ });
555
+ if (driver.now() > deadline)
556
+ return null;
557
+ await driver.sleep(GC_POLL_MS);
558
+ }
559
+ }
556
560
  var ANDROID = {
557
561
  name: "android",
558
562
  defaultDevice: "Google Pixel 7-13.0",
@@ -590,6 +594,7 @@ var ANDROID = {
590
594
  flush: {
591
595
  lookup: { using: "-android uiautomator", value: `new UiSelector().description("${FLUSH_ID}")` },
592
596
  statusAttribute: "text",
597
+ detailAttribute: "hint",
593
598
  fallback: { lookup: { using: "xpath", value: `//*[@content-desc="${FLUSH_ID}"]` }, setting: "allowInvisibleElements" }
594
599
  },
595
600
  async readScreen(driver) {
@@ -606,16 +611,18 @@ var ANDROID = {
606
611
  singleFindFirst: true,
607
612
  anyOfIds: androidAnyOfIds,
608
613
  uploadFields: {},
609
- async gcCommand(driver, n) {
610
- const out = await driver.execute("mobile: broadcast", {
611
- action: `${driver.bundleId}.keepsnappy.COMMAND`,
612
- package: driver.bundleId,
613
- extras: [["s", "cmd", "gc"], ["i", "n", String(n)]],
614
- flags: "0x10000000"
615
- });
616
- return parseBroadcastAnswer(out, n);
617
- }
614
+ gcCommand: (driver, n) => androidCommand(driver, "gc", n),
615
+ memReadCommand: (driver, n) => androidCommand(driver, "memread", n)
618
616
  };
617
+ async function androidCommand(driver, cmd, n) {
618
+ const out = await driver.execute("mobile: broadcast", {
619
+ action: `${driver.bundleId}.keepsnappy.COMMAND`,
620
+ package: driver.bundleId,
621
+ extras: [["s", "cmd", cmd], ["i", "n", String(n)]],
622
+ flags: "0x10000000"
623
+ });
624
+ return parseBroadcastAnswer(out, n, cmd);
625
+ }
619
626
  var PLATFORMS = { ios: IOS, android: ANDROID };
620
627
  function platformOf(name) {
621
628
  if (!Object.hasOwn(PLATFORMS, name))
@@ -770,6 +777,15 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
770
777
  var FLUSH_MARGIN_MS = 30000;
771
778
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
772
779
  var FLUSH_POLL_MS = 500;
780
+ var FLUSH_RESEND_MS = 90000;
781
+ var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
782
+ var FLUSH_UNREADABLE = "not acknowledged (unreadable answer)";
783
+ var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
784
+ var UNREADABLE_POLLS = 3;
785
+ var FLUSH_END_RUN_FAILED = "not acknowledged (end-run failed)";
786
+ var ACK_ECHO = / ?ack=([A-Za-z0-9_-]+)$/;
787
+ var FLUSH_ERROR = "error";
788
+ var FLUSH_OFF = "off";
773
789
  var FLUSH_FALLBACK_EVERY = 10;
774
790
  var RESCUE_EVERY_MS = 3000;
775
791
  var noop = async () => {
@@ -790,6 +806,8 @@ class Session {
790
806
  endGc;
791
807
  gcN = 0;
792
808
  stepDepth = 0;
809
+ stepLog = [];
810
+ onStepChange = null;
793
811
  refinds = 0;
794
812
  get staleRefinds() {
795
813
  return this.refinds;
@@ -818,6 +836,18 @@ class Session {
818
836
  this.actionGc = answer === `gc:${n} unavailable` ? "unavailable" : "unsupported";
819
837
  return null;
820
838
  }
839
+ memReadOff = false;
840
+ async memRead(n) {
841
+ if (n === null || this.memReadOff || this.linkState.linkMode === "safari")
842
+ return false;
843
+ const start = this.now();
844
+ const answer = await this.driver.platform.memReadCommand(this.driver, n, this.scheme, this.linkState).catch(() => null);
845
+ const ok = answer === `memread:${n} ok`;
846
+ this.driver.log.push({ ...this.driver.context, cmd: "memread", t: this.now(), ms: this.now() - start, status: ok ? 0 : 1, ...answer !== null ? { answer } : {} });
847
+ if (!ok)
848
+ this.memReadOff = true;
849
+ return ok;
850
+ }
821
851
  now() {
822
852
  return this.driver.now();
823
853
  }
@@ -842,19 +872,30 @@ class Session {
842
872
  async step(name, action, checks = noop, once = noop) {
843
873
  await this.ready();
844
874
  this.driver.context = { ...this.driver.context, step: name };
875
+ const record = { journey: this.driver.context.journey, iteration: this.driver.context.iteration, name, depth: this.stepDepth, t: this.now(), ms: null };
876
+ this.stepLog.push(record);
845
877
  this.stepDepth++;
846
878
  try {
879
+ if (record.depth === 0)
880
+ this.onStepChange?.();
847
881
  const gcN = this.stepDepth === 1 ? await this.gc() : null;
882
+ record.t = this.now();
848
883
  this.marker(name, "start", gcN);
849
884
  await action();
850
885
  await checks();
851
886
  await once();
852
887
  this.marker(name, "end");
853
888
  await checks();
889
+ } catch (e) {
890
+ record.failed = true;
891
+ throw e;
854
892
  } finally {
893
+ record.ms = this.now() - record.t;
855
894
  this.stepDepth--;
856
895
  const { step: _done, ...rest } = this.driver.context;
857
896
  this.driver.context = rest;
897
+ if (record.depth === 0)
898
+ this.onStepChange?.();
858
899
  }
859
900
  }
860
901
  isVisible(sel) {
@@ -1143,24 +1184,73 @@ class Session {
1143
1184
  await this.waitVisible(home, 30000);
1144
1185
  return this.openLink(activationLink(this.scheme, token, args));
1145
1186
  }
1187
+ lastFlush = null;
1188
+ flushSeq = 0;
1146
1189
  async endRun() {
1147
1190
  if (this.markers.some((m) => m.phase === "start"))
1148
- await this.gc(true);
1191
+ await this.memRead(await this.gc(true));
1149
1192
  await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now() });
1150
- await this.openLink(flushLink(this.scheme));
1193
+ const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1194
+ this.lastFlush = evidence;
1195
+ const acks = new Set;
1196
+ const link = () => {
1197
+ const ack = String(++this.flushSeq);
1198
+ acks.add(ack);
1199
+ return flushLink(this.scheme, ack);
1200
+ };
1201
+ await this.openLink(link());
1202
+ let answer = await this.waitFlushed(evidence.linkAt + FLUSH_RESEND_MS, acks);
1203
+ if (answer === null || "unreadable" in answer) {
1204
+ evidence.resentAt = this.now();
1205
+ if (answer !== null)
1206
+ acks.clear();
1207
+ await this.openLink(link());
1208
+ answer = await this.waitFlushed(evidence.resentAt + FLUSH_WAIT_MS, acks) ?? answer;
1209
+ }
1210
+ if (answer === null || "unreadable" in answer) {
1211
+ evidence.gaveUpAt = this.now();
1212
+ if (answer === null)
1213
+ return FLUSH_NOT_ACKNOWLEDGED;
1214
+ evidence.raw = answer.unreadable;
1215
+ return FLUSH_UNREADABLE;
1216
+ }
1217
+ evidence.ackAt = this.now();
1218
+ return answer.status;
1219
+ }
1220
+ async waitFlushed(deadline, acks) {
1151
1221
  const flush = this.driver.platform.flush;
1152
- const deadline = this.now() + FLUSH_WAIT_MS;
1222
+ let unreadable = null;
1223
+ let unreadableRun = 0;
1153
1224
  for (let poll = 1;this.now() < deadline; poll++) {
1154
1225
  let el = await this.driver.find(flush.lookup.using, flush.lookup.value);
1155
1226
  if (!el && flush.fallback && poll % FLUSH_FALLBACK_EVERY === 0)
1156
1227
  el = await this.findFallback(flush.fallback);
1157
1228
  if (el) {
1158
1229
  const label = await this.driver.cmd("GET", `/element/${el}/attribute/${flush.statusAttribute}`).catch(() => null);
1159
- return label ?? "shown";
1230
+ const raw = await this.driver.cmd("GET", `/element/${el}/attribute/${flush.detailAttribute}`).then((value) => ({ value }), () => null);
1231
+ const detail = typeof raw?.value === "string" ? raw.value : null;
1232
+ const echoed = detail === null ? null : ACK_ECHO.exec(detail);
1233
+ if (raw === null) {
1234
+ unreadable = { status: label, detail: null };
1235
+ if (++unreadableRun >= UNREADABLE_POLLS)
1236
+ return { unreadable };
1237
+ } else if (!echoed || acks.has(echoed[1])) {
1238
+ if (this.lastFlush)
1239
+ this.lastFlush.detail = detail === null ? null : detail.replace(ACK_ECHO, "");
1240
+ if (typeof label === "string" && FLUSH_STATUSES.has(label))
1241
+ return { status: label };
1242
+ unreadable = { status: label, detail };
1243
+ if (++unreadableRun >= UNREADABLE_POLLS)
1244
+ return { unreadable };
1245
+ } else {
1246
+ unreadableRun = 0;
1247
+ }
1248
+ } else {
1249
+ unreadableRun = 0;
1160
1250
  }
1161
1251
  await this.driver.sleep(FLUSH_POLL_MS);
1162
1252
  }
1163
- return null;
1253
+ return unreadable ? { unreadable } : null;
1164
1254
  }
1165
1255
  async findFallback(f) {
1166
1256
  await this.driver.cmd("POST", "/appium/settings", { settings: { [f.setting]: true } });
@@ -1190,13 +1280,17 @@ function plan(journeys, repeat, tokens) {
1190
1280
  }
1191
1281
 
1192
1282
  // packages/appium-driver/src/run.ts
1283
+ var TERMINATION_BUDGET = { captureMs: 20000, endMs: 1e4, hardMs: 45000 };
1284
+ var EXIT_CODE = { SIGTERM: 143, SIGINT: 130 };
1285
+ var realProcessHooks = { on: (s, h) => void process.on(s, h), off: (s, h) => void process.off(s, h), exit: (c) => process.exit(c) };
1193
1286
  var realRunDeps = () => ({
1194
1287
  env: process.env,
1195
1288
  fetch,
1196
1289
  now: Date.now,
1197
1290
  sleep: realSleep,
1198
1291
  print: (l) => console.log(l),
1199
- error: (l) => console.error(l)
1292
+ error: (l) => console.error(l),
1293
+ process: realProcessHooks
1200
1294
  });
1201
1295
  var DRIVER_OPTIONS = {
1202
1296
  "journey-module": { type: "string" },
@@ -1222,6 +1316,16 @@ function parseDriverArgs(argv, module) {
1222
1316
  function journeyModulePath(argv) {
1223
1317
  return parseArgs({ args: [...argv], options: { "journey-module": { type: "string" } }, strict: false }).values["journey-module"];
1224
1318
  }
1319
+ function writeAtomic(file, text) {
1320
+ const tmp = `${file}.${process.pid}.tmp`;
1321
+ writeFileSync(tmp, text);
1322
+ renameSync(tmp, file);
1323
+ }
1324
+ async function captureFailure(driver, out) {
1325
+ const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(join(out, "failure-source.xml"), s)), () => null);
1326
+ const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(join(out, "failure-screen.png"), Buffer.from(png, "base64"))), () => null);
1327
+ await Promise.all([source, screen]);
1328
+ }
1225
1329
  async function runDriver(argv, module, deps = realRunDeps()) {
1226
1330
  const a = parseDriverArgs(argv, module);
1227
1331
  const user = deps.env.BROWSERSTACK_USERNAME;
@@ -1246,66 +1350,210 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1246
1350
  const auth = basicAuth(user, key);
1247
1351
  mkdirSync(out, { recursive: true });
1248
1352
  const { api, tokens } = parseTokensJs(readFileSync(String(a["tokens-js"]), "utf8"));
1249
- const appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
1250
- deps.print(`app: ${appUrl}`);
1251
1353
  const log = [];
1252
1354
  const driver = new Driver({ hub: HUB, log, headers: { Authorization: auth }, bundleId: module.app.bundleId, platform, fetch: deps.fetch, now: deps.now, sleep: deps.sleep });
1253
1355
  const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
1254
1356
  const sessionName = module.sessionName ?? "keepsnappy (appium)";
1255
1357
  const device = String(a.device);
1256
1358
  const ctx = { fetch: deps.fetch, tokens, journeys, repeat, args: a };
1257
- const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"] });
1258
- const startedAt = deps.now();
1359
+ let startedAt = deps.now();
1360
+ let endedAt = null;
1361
+ let appUrl = null;
1362
+ let buildId = null;
1363
+ let status = "running";
1364
+ let signal = null;
1259
1365
  let error = null;
1366
+ let finalFlush = null;
1367
+ let flushNotAcknowledged = [];
1368
+ let flushError = [];
1369
+ let flushOff = [];
1260
1370
  const iterations = [];
1261
- try {
1262
- await driver.start(caps);
1263
- deps.print(`session ${driver.sessionId}`);
1264
- await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
1265
- await module.setup?.(t, ctx);
1266
- await runIterations(t, module, ctx, iterations);
1267
- } catch (e) {
1268
- error = e instanceof Error ? e : new Error(String(e));
1269
- deps.error(`::error::${error.message}`);
1270
- if (driver.sessionId) {
1271
- const source = await driver.cmd("GET", "/source").catch(() => null);
1272
- if (typeof source === "string")
1273
- writeFileSync(join(out, "failure-source.xml"), source);
1274
- const png = await driver.cmd("GET", "/screenshot").catch(() => null);
1275
- if (typeof png === "string")
1276
- writeFileSync(join(out, "failure-screen.png"), Buffer.from(png, "base64"));
1371
+ let terminating = false;
1372
+ const logFile = join(out, "driver-log.json");
1373
+ const persist = () => {
1374
+ const last = t.stepLog.at(-1);
1375
+ const body = {
1376
+ driver: "appium",
1377
+ status,
1378
+ device,
1379
+ appUrl,
1380
+ session: driver.sessionId,
1381
+ buildId,
1382
+ linkMode: t.linkState.linkMode ?? null,
1383
+ actionGc: t.actionGc,
1384
+ endGc: t.endGc,
1385
+ linkError: t.linkState.linkError ?? null,
1386
+ startedAt,
1387
+ endedAt,
1388
+ total_ms: (endedAt ?? deps.now()) - startedAt,
1389
+ error: error?.message ?? null,
1390
+ signal,
1391
+ lastStep: last ? { name: last.name, t: last.t, journey: last.journey ?? null, iteration: last.iteration ?? null } : null,
1392
+ flushNotAcknowledged,
1393
+ flushError,
1394
+ flushOff,
1395
+ finalFlush,
1396
+ steps: t.stepLog,
1397
+ iterations,
1398
+ commands: log
1399
+ };
1400
+ writeAtomic(logFile, `${JSON.stringify(body, null, 2)}
1401
+ `);
1402
+ };
1403
+ const progress = () => {
1404
+ if (terminating)
1405
+ return;
1406
+ try {
1407
+ persist();
1408
+ } catch (e) {
1409
+ deps.error(`::warning::driver-log.json not written: ${e instanceof Error ? e.message : String(e)}`);
1277
1410
  }
1278
- }
1279
- if (driver.sessionId) {
1280
- driver.context = { phase: "final-flush" };
1281
- await t.endRun().catch(() => null);
1282
- const reason = error ? error.message.slice(0, 250) : `${sessionName} done`;
1283
- await driver.execute(`browserstack_executor: ${JSON.stringify({ action: "setSessionStatus", arguments: { status: error ? "failed" : "passed", reason } })}`).catch(() => null);
1284
- }
1285
- const endedAt = deps.now();
1286
- await driver.end();
1287
- const detail = driver.sessionId ? await sessionDetail(driver.sessionId, auth, deps.fetch) : {};
1288
- const buildId = detail.build_hashed_id ?? null;
1289
- writeFileSync(join(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
1411
+ };
1412
+ let dashboard = null;
1413
+ const writeBuild = (failure) => writeAtomic(join(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
1290
1414
  driver: "appium",
1291
- status: error ? "failed" : "passed",
1415
+ status: failure === null ? "passed" : "failed",
1292
1416
  buildId,
1293
- dashboard: detail.public_url ?? detail.browser_url ?? null,
1294
- devices: [{ device, sessions: [{ id: driver.sessionId, ...error ? { error: { message: error.message } } : {} }] }]
1417
+ dashboard,
1418
+ devices: [{ device, sessions: [{ id: driver.sessionId, ...failure === null ? {} : { error: { message: failure } } }] }]
1295
1419
  }, null, 2)}
1296
1420
  `);
1297
- writeFileSync(join(out, "driver-log.json"), `${JSON.stringify({ driver: "appium", device, appUrl, session: driver.sessionId, buildId, linkMode: t.linkState.linkMode ?? null, actionGc: t.actionGc, endGc: t.endGc, linkError: t.linkState.linkError ?? null, startedAt, endedAt, total_ms: endedAt - startedAt, error: error?.message ?? null, iterations, commands: log }, null, 2)}
1298
- `);
1299
- deps.print(`driver-log.json: ${log.length} commands, ${iterations.length} iterations, ${Math.round((endedAt - startedAt) / 1000)} s`);
1300
- for (const r of iterations)
1301
- deps.print(` ${r.journey} #${r.iteration}: ${r.total_ms ?? "?"} ms, flushed=${r.flushed}`);
1302
- return error ? 1 : 0;
1421
+ const readDetail = async () => {
1422
+ const detail = driver.sessionId ? await sessionDetail(driver.sessionId, auth, deps.fetch) : {};
1423
+ buildId = detail.build_hashed_id ?? null;
1424
+ dashboard = detail.public_url ?? detail.browser_url ?? null;
1425
+ };
1426
+ const setSessionStatus = (failed, reason) => driver.execute(`browserstack_executor: ${JSON.stringify({ action: "setSessionStatus", arguments: { status: failed ? "failed" : "passed", reason } })}`).catch(() => null);
1427
+ const proc = deps.process;
1428
+ const budget = { ...TERMINATION_BUDGET, ...deps.term };
1429
+ const timers = new Set;
1430
+ const within = async (ms, work) => {
1431
+ let timer;
1432
+ await Promise.race([work.catch(() => null), new Promise((resolve) => timers.add(timer = setTimeout(resolve, ms)))]);
1433
+ clearTimeout(timer);
1434
+ };
1435
+ const leave = (code) => {
1436
+ for (const timer of timers)
1437
+ clearTimeout(timer);
1438
+ timers.clear();
1439
+ proc?.exit(code);
1440
+ };
1441
+ const onSignal = (sig) => async () => {
1442
+ if (terminating)
1443
+ return leave(EXIT_CODE[sig]);
1444
+ terminating = true;
1445
+ signal = sig;
1446
+ status = "terminated";
1447
+ error ??= new Error("terminated");
1448
+ endedAt = deps.now();
1449
+ const stopped = t.stepLog.at(-1);
1450
+ deps.error(`::error::terminated by ${sig}${stopped ? ` in step ${stopped.name}` : ""}`);
1451
+ try {
1452
+ persist();
1453
+ } catch (e) {
1454
+ deps.error(`::warning::driver-log.json not written: ${e instanceof Error ? e.message : String(e)}`);
1455
+ }
1456
+ timers.add(setTimeout(() => leave(EXIT_CODE[sig]), budget.hardMs));
1457
+ try {
1458
+ if (driver.sessionId) {
1459
+ await within(budget.captureMs, captureFailure(driver, out));
1460
+ await within(budget.endMs, (async () => {
1461
+ await setSessionStatus(true, `terminated by ${sig}`.slice(0, 250));
1462
+ await driver.end();
1463
+ await readDetail();
1464
+ })());
1465
+ writeBuild(`terminated by ${sig}`);
1466
+ }
1467
+ endedAt = deps.now();
1468
+ persist();
1469
+ } catch (e) {
1470
+ deps.error(`::warning::termination cleanup failed: ${e instanceof Error ? e.message : String(e)}`);
1471
+ } finally {
1472
+ leave(EXIT_CODE[sig]);
1473
+ }
1474
+ };
1475
+ const handlers = ["SIGTERM", "SIGINT"].map((sig) => [sig, onSignal(sig)]);
1476
+ for (const [sig, handler] of handlers)
1477
+ proc?.on(sig, handler);
1478
+ t.onStepChange = progress;
1479
+ progress();
1480
+ try {
1481
+ appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
1482
+ deps.print(`app: ${appUrl}`);
1483
+ const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"] });
1484
+ startedAt = deps.now();
1485
+ try {
1486
+ await driver.start(caps);
1487
+ deps.print(`session ${driver.sessionId}`);
1488
+ progress();
1489
+ await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
1490
+ await module.setup?.(t, ctx);
1491
+ await runIterations(t, module, ctx, iterations, progress);
1492
+ } catch (e) {
1493
+ error = e instanceof Error ? e : new Error(String(e));
1494
+ deps.error(`::error::${error.message}`);
1495
+ progress();
1496
+ if (driver.sessionId)
1497
+ await captureFailure(driver, out);
1498
+ }
1499
+ if (driver.sessionId) {
1500
+ driver.context = { phase: "final-flush" };
1501
+ t.lastFlush = null;
1502
+ progress();
1503
+ try {
1504
+ finalFlush = { flushed: await t.endRun(), flush: t.lastFlush };
1505
+ } catch (e) {
1506
+ const started = t.lastFlush ?? { linkAt: null, resentAt: null, ackAt: null, gaveUpAt: null };
1507
+ finalFlush = { flushed: FLUSH_END_RUN_FAILED, flush: { ...started, gaveUpAt: deps.now(), error: e instanceof Error ? e.message : String(e) } };
1508
+ }
1509
+ progress();
1510
+ await setSessionStatus(!!error, error ? error.message.slice(0, 250) : `${sessionName} done`);
1511
+ }
1512
+ const flushedAs = (flushStatus, tokenOnly = false) => [
1513
+ ...iterations.filter((r) => r.flushed === flushStatus && (!tokenOnly || r.token)).map((r) => `${r.journey} #${r.iteration}`),
1514
+ ...finalFlush?.flushed === flushStatus && (!tokenOnly || iterations.some((r) => r.token)) ? ["final flush"] : []
1515
+ ];
1516
+ flushNotAcknowledged = [...flushedAs(FLUSH_NOT_ACKNOWLEDGED), ...flushedAs(FLUSH_UNREADABLE), ...flushedAs(FLUSH_END_RUN_FAILED)];
1517
+ flushError = flushedAs(FLUSH_ERROR);
1518
+ flushOff = flushedAs(FLUSH_OFF, true);
1519
+ if (flushNotAcknowledged.length)
1520
+ deps.error(`::error::flush not acknowledged (no answer after the re-send, an unreadable answer, or a failed end-run): ${flushNotAcknowledged.join(", ")}`);
1521
+ if (flushError.length)
1522
+ deps.error(`::error::flush answered error (data not uploaded): ${flushError.join(", ")}`);
1523
+ if (flushOff.length)
1524
+ deps.error(`::error::flush answered off in a run with a token (upload not configured, nothing uploaded): ${flushOff.join(", ")}`);
1525
+ endedAt = deps.now();
1526
+ await driver.end();
1527
+ await readDetail();
1528
+ const failed = !!(error || flushNotAcknowledged.length || flushError.length || flushOff.length);
1529
+ if (terminating)
1530
+ return EXIT_CODE[signal ?? "SIGTERM"];
1531
+ writeBuild(error?.message ?? null);
1532
+ status = failed ? "error" : "done";
1533
+ persist();
1534
+ deps.print(`driver-log.json: ${log.length} commands, ${iterations.length} iterations, ${Math.round((endedAt - startedAt) / 1000)} s`);
1535
+ for (const r of iterations)
1536
+ deps.print(` ${r.journey} #${r.iteration}: ${r.total_ms ?? "?"} ms, flushed=${r.flushed}`);
1537
+ return failed ? 1 : 0;
1538
+ } catch (e) {
1539
+ if (!terminating) {
1540
+ status = "error";
1541
+ error ??= e instanceof Error ? e : new Error(String(e));
1542
+ endedAt = deps.now();
1543
+ progress();
1544
+ }
1545
+ throw e;
1546
+ } finally {
1547
+ for (const [sig, handler] of handlers)
1548
+ proc?.off(sig, handler);
1549
+ }
1303
1550
  }
1304
- async function runIterations(t, module, ctx, iterations) {
1551
+ async function runIterations(t, module, ctx, iterations, progress) {
1305
1552
  const { driver } = t;
1306
1553
  for (const it of plan(ctx.journeys, ctx.repeat, ctx.tokens)) {
1307
1554
  const rec = { journey: it.journey, iteration: it.iteration, token: it.token !== "" };
1308
1555
  iterations.push(rec);
1556
+ progress();
1309
1557
  driver.context = { journey: it.journey, iteration: it.iteration, phase: "begin" };
1310
1558
  const t0 = t.now();
1311
1559
  rec.alert = await t.beginRun({ token: it.token, home: module.home, ...module.sdkArgs ? { args: module.sdkArgs } : {} });
@@ -1321,8 +1569,10 @@ async function runIterations(t, module, ctx, iterations) {
1321
1569
  rec.ready = { n: waits.length, ms: waits.reduce((x, y) => x + y, 0) };
1322
1570
  driver.context = { ...driver.context, phase: "end" };
1323
1571
  rec.flushed = await t.endRun();
1572
+ rec.flush = t.lastFlush;
1324
1573
  const t3 = t.now();
1325
1574
  Object.assign(rec, { t_start: t0, begin_ms: t1 - t0, journey_ms: t2 - t1, end_ms: t3 - t2, total_ms: t3 - t0 });
1575
+ progress();
1326
1576
  }
1327
1577
  }
1328
1578