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

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 (;; ) {
@@ -347,6 +348,10 @@ class Driver {
347
348
  }
348
349
  return value;
349
350
  }
351
+ async attribute(el, name) {
352
+ const value = await this.cmd("GET", `/element/${el}/attribute/${name}`);
353
+ return value === this.platform.absentAttributeText ? null : value;
354
+ }
350
355
  async start(capabilities) {
351
356
  const value = await this.cmd("POST", "/session", { capabilities: { alwaysMatch: capabilities } });
352
357
  this.sessionId = value.sessionId;
@@ -426,7 +431,7 @@ class Driver {
426
431
  break;
427
432
  let resourceId;
428
433
  try {
429
- resourceId = String(await this.cmd("GET", `/element/${el}/attribute/resource-id`));
434
+ resourceId = String(await this.attribute(el, "resource-id") ?? "");
430
435
  } catch (e) {
431
436
  if (e instanceof WdError && e.wdError === "stale element reference")
432
437
  continue;
@@ -510,9 +515,10 @@ var FLUSH_ID = "keepsnappy.flushed";
510
515
  var ACK_ID = "keepsnappy.ack";
511
516
  var GC_ANSWER_WAIT_MS = 1e4;
512
517
  var GC_POLL_MS = 100;
513
- function parseBroadcastAnswer(out, n) {
518
+ var MEMREAD_ANSWER_WAIT_MS = 3000;
519
+ function parseBroadcastAnswer(out, n, cmd = "gc") {
514
520
  const data = /data="([^"]*)"/.exec(String(out ?? ""))?.[1];
515
- return data !== undefined && data.startsWith(`gc:${n} `) ? data : null;
521
+ return data !== undefined && data.startsWith(`${cmd}:${n} `) ? data : null;
516
522
  }
517
523
  var NO_WAIT_MS = 0;
518
524
  var NEW_COMMAND_TIMEOUT_S = 300;
@@ -528,31 +534,34 @@ var IOS = {
528
534
  "appium:newCommandTimeout": NEW_COMMAND_TIMEOUT_S,
529
535
  "appium:waitForIdleTimeout": NO_WAIT_MS
530
536
  }),
537
+ absentAttributeText: null,
531
538
  settings: { animationCoolOffTimeout: NO_WAIT_MS, waitForIdleTimeout: NO_WAIT_MS, shouldUseCompactResponses: true },
532
539
  locators: (sel) => [locator(sel)],
533
540
  keyboardShown: async (driver) => Boolean(await driver.findShown(id("Return"))),
534
541
  appArg: (bundleId) => ({ bundleId }),
535
542
  openLink: (driver, url, state) => openLink(driver, url, state),
536
543
  acceptAlert: (driver, labels, waitMs) => acceptAlert(driver, labels, waitMs),
537
- flush: { lookup: { using: "accessibility id", value: FLUSH_ID }, statusAttribute: "label" },
544
+ flush: { lookup: { using: "accessibility id", value: FLUSH_ID }, statusAttribute: "label", detailAttribute: "value" },
538
545
  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
- }
546
+ gcCommand: (driver, n, scheme, state) => iosCommand(driver, gcLink(scheme, n), "gc", n, state, GC_ANSWER_WAIT_MS),
547
+ memReadCommand: (driver, n, scheme, state) => iosCommand(driver, memReadLink(scheme, n), "memread", n, state, MEMREAD_ANSWER_WAIT_MS)
555
548
  };
549
+ async function iosCommand(driver, link, cmd, n, state, waitMs) {
550
+ await openLink(driver, link, state);
551
+ const want = `type == "XCUIElementTypeStaticText" AND name == "${ACK_ID}" AND label BEGINSWITH "${cmd}:${n} "`;
552
+ const deadline = driver.now() + waitMs;
553
+ for (;; ) {
554
+ const el = await driver.find("-ios predicate string", want);
555
+ if (el)
556
+ return driver.attribute(el, "label").then(String).catch(() => {
557
+ driver.log.push({ ...driver.context, cmd: `${cmd}-ack-label`, t: driver.now(), ms: 0, status: 1 });
558
+ return `${cmd}:${n} ok`;
559
+ });
560
+ if (driver.now() > deadline)
561
+ return null;
562
+ await driver.sleep(GC_POLL_MS);
563
+ }
564
+ }
556
565
  var ANDROID = {
557
566
  name: "android",
558
567
  defaultDevice: "Google Pixel 7-13.0",
@@ -564,6 +573,7 @@ var ANDROID = {
564
573
  "appium:autoGrantPermissions": true,
565
574
  "appium:newCommandTimeout": NEW_COMMAND_TIMEOUT_S
566
575
  }),
576
+ absentAttributeText: "null",
567
577
  settings: { waitForIdleTimeout: NO_WAIT_MS, actionAcknowledgmentTimeout: NO_WAIT_MS, waitForSelectorTimeout: NO_WAIT_MS, enableMultiWindows: true, ignoreUnimportantViews: true },
568
578
  locators: androidLocators,
569
579
  appArg: (appId) => ({ appId }),
@@ -590,6 +600,7 @@ var ANDROID = {
590
600
  flush: {
591
601
  lookup: { using: "-android uiautomator", value: `new UiSelector().description("${FLUSH_ID}")` },
592
602
  statusAttribute: "text",
603
+ detailAttribute: "hint",
593
604
  fallback: { lookup: { using: "xpath", value: `//*[@content-desc="${FLUSH_ID}"]` }, setting: "allowInvisibleElements" }
594
605
  },
595
606
  async readScreen(driver) {
@@ -606,16 +617,18 @@ var ANDROID = {
606
617
  singleFindFirst: true,
607
618
  anyOfIds: androidAnyOfIds,
608
619
  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
- }
620
+ gcCommand: (driver, n) => androidCommand(driver, "gc", n),
621
+ memReadCommand: (driver, n) => androidCommand(driver, "memread", n)
618
622
  };
623
+ async function androidCommand(driver, cmd, n) {
624
+ const out = await driver.execute("mobile: broadcast", {
625
+ action: `${driver.bundleId}.keepsnappy.COMMAND`,
626
+ package: driver.bundleId,
627
+ extras: [["s", "cmd", cmd], ["i", "n", String(n)]],
628
+ flags: "0x10000000"
629
+ });
630
+ return parseBroadcastAnswer(out, n, cmd);
631
+ }
619
632
  var PLATFORMS = { ios: IOS, android: ANDROID };
620
633
  function platformOf(name) {
621
634
  if (!Object.hasOwn(PLATFORMS, name))
@@ -770,6 +783,16 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
770
783
  var FLUSH_MARGIN_MS = 30000;
771
784
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
772
785
  var FLUSH_POLL_MS = 500;
786
+ var FLUSH_RESEND_MS = 90000;
787
+ var ACTIVATION_SETTLE_MS = 2000;
788
+ var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
789
+ var FLUSH_UNREADABLE = "not acknowledged (unreadable answer)";
790
+ var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
791
+ var UNREADABLE_POLLS = 3;
792
+ var FLUSH_END_RUN_FAILED = "not acknowledged (end-run failed)";
793
+ var ACK_ECHO = / ?ack=([A-Za-z0-9_-]+)$/;
794
+ var FLUSH_ERROR = "error";
795
+ var FLUSH_OFF = "off";
773
796
  var FLUSH_FALLBACK_EVERY = 10;
774
797
  var RESCUE_EVERY_MS = 3000;
775
798
  var noop = async () => {
@@ -790,6 +813,8 @@ class Session {
790
813
  endGc;
791
814
  gcN = 0;
792
815
  stepDepth = 0;
816
+ stepLog = [];
817
+ onStepChange = null;
793
818
  refinds = 0;
794
819
  get staleRefinds() {
795
820
  return this.refinds;
@@ -818,6 +843,18 @@ class Session {
818
843
  this.actionGc = answer === `gc:${n} unavailable` ? "unavailable" : "unsupported";
819
844
  return null;
820
845
  }
846
+ memReadOff = false;
847
+ async memRead(n) {
848
+ if (n === null || this.memReadOff || this.linkState.linkMode === "safari")
849
+ return false;
850
+ const start = this.now();
851
+ const answer = await this.driver.platform.memReadCommand(this.driver, n, this.scheme, this.linkState).catch(() => null);
852
+ const ok = answer === `memread:${n} ok`;
853
+ this.driver.log.push({ ...this.driver.context, cmd: "memread", t: this.now(), ms: this.now() - start, status: ok ? 0 : 1, ...answer !== null ? { answer } : {} });
854
+ if (!ok)
855
+ this.memReadOff = true;
856
+ return ok;
857
+ }
821
858
  now() {
822
859
  return this.driver.now();
823
860
  }
@@ -842,19 +879,30 @@ class Session {
842
879
  async step(name, action, checks = noop, once = noop) {
843
880
  await this.ready();
844
881
  this.driver.context = { ...this.driver.context, step: name };
882
+ const record = { journey: this.driver.context.journey, iteration: this.driver.context.iteration, name, depth: this.stepDepth, t: this.now(), ms: null };
883
+ this.stepLog.push(record);
845
884
  this.stepDepth++;
846
885
  try {
886
+ if (record.depth === 0)
887
+ this.onStepChange?.();
847
888
  const gcN = this.stepDepth === 1 ? await this.gc() : null;
889
+ record.t = this.now();
848
890
  this.marker(name, "start", gcN);
849
891
  await action();
850
892
  await checks();
851
893
  await once();
852
894
  this.marker(name, "end");
853
895
  await checks();
896
+ } catch (e) {
897
+ record.failed = true;
898
+ throw e;
854
899
  } finally {
900
+ record.ms = this.now() - record.t;
855
901
  this.stepDepth--;
856
902
  const { step: _done, ...rest } = this.driver.context;
857
903
  this.driver.context = rest;
904
+ if (record.depth === 0)
905
+ this.onStepChange?.();
858
906
  }
859
907
  }
860
908
  isVisible(sel) {
@@ -1141,26 +1189,77 @@ class Session {
1141
1189
  this.pendingReady = null;
1142
1190
  await this.relaunch();
1143
1191
  await this.waitVisible(home, 30000);
1144
- return this.openLink(activationLink(this.scheme, token, args));
1192
+ const alert = await this.openLink(activationLink(this.scheme, token, args));
1193
+ await this.driver.sleep(ACTIVATION_SETTLE_MS);
1194
+ return alert;
1145
1195
  }
1196
+ lastFlush = null;
1197
+ flushSeq = 0;
1146
1198
  async endRun() {
1147
1199
  if (this.markers.some((m) => m.phase === "start"))
1148
- await this.gc(true);
1200
+ await this.memRead(await this.gc(true));
1149
1201
  await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now() });
1150
- await this.openLink(flushLink(this.scheme));
1202
+ const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
1203
+ this.lastFlush = evidence;
1204
+ const acks = new Set;
1205
+ const link = () => {
1206
+ const ack = String(++this.flushSeq);
1207
+ acks.add(ack);
1208
+ return flushLink(this.scheme, ack);
1209
+ };
1210
+ await this.openLink(link());
1211
+ let answer = await this.waitFlushed(evidence.linkAt + FLUSH_RESEND_MS, acks);
1212
+ if (answer === null || "unreadable" in answer) {
1213
+ evidence.resentAt = this.now();
1214
+ if (answer !== null)
1215
+ acks.clear();
1216
+ await this.openLink(link());
1217
+ answer = await this.waitFlushed(evidence.resentAt + FLUSH_WAIT_MS, acks) ?? answer;
1218
+ }
1219
+ if (answer === null || "unreadable" in answer) {
1220
+ evidence.gaveUpAt = this.now();
1221
+ if (answer === null)
1222
+ return FLUSH_NOT_ACKNOWLEDGED;
1223
+ evidence.raw = answer.unreadable;
1224
+ return FLUSH_UNREADABLE;
1225
+ }
1226
+ evidence.ackAt = this.now();
1227
+ return answer.status;
1228
+ }
1229
+ async waitFlushed(deadline, acks) {
1151
1230
  const flush = this.driver.platform.flush;
1152
- const deadline = this.now() + FLUSH_WAIT_MS;
1231
+ let unreadable = null;
1232
+ let unreadableRun = 0;
1153
1233
  for (let poll = 1;this.now() < deadline; poll++) {
1154
1234
  let el = await this.driver.find(flush.lookup.using, flush.lookup.value);
1155
1235
  if (!el && flush.fallback && poll % FLUSH_FALLBACK_EVERY === 0)
1156
1236
  el = await this.findFallback(flush.fallback);
1157
1237
  if (el) {
1158
- const label = await this.driver.cmd("GET", `/element/${el}/attribute/${flush.statusAttribute}`).catch(() => null);
1159
- return label ?? "shown";
1238
+ const label = await this.driver.attribute(el, flush.statusAttribute).catch(() => null);
1239
+ const raw = await this.driver.attribute(el, flush.detailAttribute).then((value) => ({ value }), () => null);
1240
+ const detail = typeof raw?.value === "string" ? raw.value : null;
1241
+ const echoed = detail === null ? null : ACK_ECHO.exec(detail);
1242
+ if (raw === null) {
1243
+ unreadable = { status: label, detail: null };
1244
+ if (++unreadableRun >= UNREADABLE_POLLS)
1245
+ return { unreadable };
1246
+ } else if (!echoed || acks.has(echoed[1])) {
1247
+ if (this.lastFlush)
1248
+ this.lastFlush.detail = detail === null ? null : detail.replace(ACK_ECHO, "");
1249
+ if (typeof label === "string" && FLUSH_STATUSES.has(label))
1250
+ return { status: label };
1251
+ unreadable = { status: label, detail };
1252
+ if (++unreadableRun >= UNREADABLE_POLLS)
1253
+ return { unreadable };
1254
+ } else {
1255
+ unreadableRun = 0;
1256
+ }
1257
+ } else {
1258
+ unreadableRun = 0;
1160
1259
  }
1161
1260
  await this.driver.sleep(FLUSH_POLL_MS);
1162
1261
  }
1163
- return null;
1262
+ return unreadable ? { unreadable } : null;
1164
1263
  }
1165
1264
  async findFallback(f) {
1166
1265
  await this.driver.cmd("POST", "/appium/settings", { settings: { [f.setting]: true } });
@@ -1190,13 +1289,17 @@ function plan(journeys, repeat, tokens) {
1190
1289
  }
1191
1290
 
1192
1291
  // packages/appium-driver/src/run.ts
1292
+ var TERMINATION_BUDGET = { captureMs: 20000, endMs: 1e4, hardMs: 45000 };
1293
+ var EXIT_CODE = { SIGTERM: 143, SIGINT: 130 };
1294
+ var realProcessHooks = { on: (s, h) => void process.on(s, h), off: (s, h) => void process.off(s, h), exit: (c) => process.exit(c) };
1193
1295
  var realRunDeps = () => ({
1194
1296
  env: process.env,
1195
1297
  fetch,
1196
1298
  now: Date.now,
1197
1299
  sleep: realSleep,
1198
1300
  print: (l) => console.log(l),
1199
- error: (l) => console.error(l)
1301
+ error: (l) => console.error(l),
1302
+ process: realProcessHooks
1200
1303
  });
1201
1304
  var DRIVER_OPTIONS = {
1202
1305
  "journey-module": { type: "string" },
@@ -1222,6 +1325,16 @@ function parseDriverArgs(argv, module) {
1222
1325
  function journeyModulePath(argv) {
1223
1326
  return parseArgs({ args: [...argv], options: { "journey-module": { type: "string" } }, strict: false }).values["journey-module"];
1224
1327
  }
1328
+ function writeAtomic(file, text) {
1329
+ const tmp = `${file}.${process.pid}.tmp`;
1330
+ writeFileSync(tmp, text);
1331
+ renameSync(tmp, file);
1332
+ }
1333
+ async function captureFailure(driver, out) {
1334
+ const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(join(out, "failure-source.xml"), s)), () => null);
1335
+ const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(join(out, "failure-screen.png"), Buffer.from(png, "base64"))), () => null);
1336
+ await Promise.all([source, screen]);
1337
+ }
1225
1338
  async function runDriver(argv, module, deps = realRunDeps()) {
1226
1339
  const a = parseDriverArgs(argv, module);
1227
1340
  const user = deps.env.BROWSERSTACK_USERNAME;
@@ -1246,66 +1359,210 @@ async function runDriver(argv, module, deps = realRunDeps()) {
1246
1359
  const auth = basicAuth(user, key);
1247
1360
  mkdirSync(out, { recursive: true });
1248
1361
  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
1362
  const log = [];
1252
1363
  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
1364
  const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
1254
1365
  const sessionName = module.sessionName ?? "keepsnappy (appium)";
1255
1366
  const device = String(a.device);
1256
1367
  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();
1368
+ let startedAt = deps.now();
1369
+ let endedAt = null;
1370
+ let appUrl = null;
1371
+ let buildId = null;
1372
+ let status = "running";
1373
+ let signal = null;
1259
1374
  let error = null;
1375
+ let finalFlush = null;
1376
+ let flushNotAcknowledged = [];
1377
+ let flushError = [];
1378
+ let flushOff = [];
1260
1379
  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"));
1380
+ let terminating = false;
1381
+ const logFile = join(out, "driver-log.json");
1382
+ const persist = () => {
1383
+ const last = t.stepLog.at(-1);
1384
+ const body = {
1385
+ driver: "appium",
1386
+ status,
1387
+ device,
1388
+ appUrl,
1389
+ session: driver.sessionId,
1390
+ buildId,
1391
+ linkMode: t.linkState.linkMode ?? null,
1392
+ actionGc: t.actionGc,
1393
+ endGc: t.endGc,
1394
+ linkError: t.linkState.linkError ?? null,
1395
+ startedAt,
1396
+ endedAt,
1397
+ total_ms: (endedAt ?? deps.now()) - startedAt,
1398
+ error: error?.message ?? null,
1399
+ signal,
1400
+ lastStep: last ? { name: last.name, t: last.t, journey: last.journey ?? null, iteration: last.iteration ?? null } : null,
1401
+ flushNotAcknowledged,
1402
+ flushError,
1403
+ flushOff,
1404
+ finalFlush,
1405
+ steps: t.stepLog,
1406
+ iterations,
1407
+ commands: log
1408
+ };
1409
+ writeAtomic(logFile, `${JSON.stringify(body, null, 2)}
1410
+ `);
1411
+ };
1412
+ const progress = () => {
1413
+ if (terminating)
1414
+ return;
1415
+ try {
1416
+ persist();
1417
+ } catch (e) {
1418
+ deps.error(`::warning::driver-log.json not written: ${e instanceof Error ? e.message : String(e)}`);
1277
1419
  }
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({
1420
+ };
1421
+ let dashboard = null;
1422
+ const writeBuild = (failure) => writeAtomic(join(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
1290
1423
  driver: "appium",
1291
- status: error ? "failed" : "passed",
1424
+ status: failure === null ? "passed" : "failed",
1292
1425
  buildId,
1293
- dashboard: detail.public_url ?? detail.browser_url ?? null,
1294
- devices: [{ device, sessions: [{ id: driver.sessionId, ...error ? { error: { message: error.message } } : {} }] }]
1426
+ dashboard,
1427
+ devices: [{ device, sessions: [{ id: driver.sessionId, ...failure === null ? {} : { error: { message: failure } } }] }]
1295
1428
  }, null, 2)}
1296
1429
  `);
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;
1430
+ const readDetail = async () => {
1431
+ const detail = driver.sessionId ? await sessionDetail(driver.sessionId, auth, deps.fetch) : {};
1432
+ buildId = detail.build_hashed_id ?? null;
1433
+ dashboard = detail.public_url ?? detail.browser_url ?? null;
1434
+ };
1435
+ const setSessionStatus = (failed, reason) => driver.execute(`browserstack_executor: ${JSON.stringify({ action: "setSessionStatus", arguments: { status: failed ? "failed" : "passed", reason } })}`).catch(() => null);
1436
+ const proc = deps.process;
1437
+ const budget = { ...TERMINATION_BUDGET, ...deps.term };
1438
+ const timers = new Set;
1439
+ const within = async (ms, work) => {
1440
+ let timer;
1441
+ await Promise.race([work.catch(() => null), new Promise((resolve) => timers.add(timer = setTimeout(resolve, ms)))]);
1442
+ clearTimeout(timer);
1443
+ };
1444
+ const leave = (code) => {
1445
+ for (const timer of timers)
1446
+ clearTimeout(timer);
1447
+ timers.clear();
1448
+ proc?.exit(code);
1449
+ };
1450
+ const onSignal = (sig) => async () => {
1451
+ if (terminating)
1452
+ return leave(EXIT_CODE[sig]);
1453
+ terminating = true;
1454
+ signal = sig;
1455
+ status = "terminated";
1456
+ error ??= new Error("terminated");
1457
+ endedAt = deps.now();
1458
+ const stopped = t.stepLog.at(-1);
1459
+ deps.error(`::error::terminated by ${sig}${stopped ? ` in step ${stopped.name}` : ""}`);
1460
+ try {
1461
+ persist();
1462
+ } catch (e) {
1463
+ deps.error(`::warning::driver-log.json not written: ${e instanceof Error ? e.message : String(e)}`);
1464
+ }
1465
+ timers.add(setTimeout(() => leave(EXIT_CODE[sig]), budget.hardMs));
1466
+ try {
1467
+ if (driver.sessionId) {
1468
+ await within(budget.captureMs, captureFailure(driver, out));
1469
+ await within(budget.endMs, (async () => {
1470
+ await setSessionStatus(true, `terminated by ${sig}`.slice(0, 250));
1471
+ await driver.end();
1472
+ await readDetail();
1473
+ })());
1474
+ writeBuild(`terminated by ${sig}`);
1475
+ }
1476
+ endedAt = deps.now();
1477
+ persist();
1478
+ } catch (e) {
1479
+ deps.error(`::warning::termination cleanup failed: ${e instanceof Error ? e.message : String(e)}`);
1480
+ } finally {
1481
+ leave(EXIT_CODE[sig]);
1482
+ }
1483
+ };
1484
+ const handlers = ["SIGTERM", "SIGINT"].map((sig) => [sig, onSignal(sig)]);
1485
+ for (const [sig, handler] of handlers)
1486
+ proc?.on(sig, handler);
1487
+ t.onStepChange = progress;
1488
+ progress();
1489
+ try {
1490
+ appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
1491
+ deps.print(`app: ${appUrl}`);
1492
+ const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"] });
1493
+ startedAt = deps.now();
1494
+ try {
1495
+ await driver.start(caps);
1496
+ deps.print(`session ${driver.sessionId}`);
1497
+ progress();
1498
+ await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
1499
+ await module.setup?.(t, ctx);
1500
+ await runIterations(t, module, ctx, iterations, progress);
1501
+ } catch (e) {
1502
+ error = e instanceof Error ? e : new Error(String(e));
1503
+ deps.error(`::error::${error.message}`);
1504
+ progress();
1505
+ if (driver.sessionId)
1506
+ await captureFailure(driver, out);
1507
+ }
1508
+ if (driver.sessionId) {
1509
+ driver.context = { phase: "final-flush" };
1510
+ t.lastFlush = null;
1511
+ progress();
1512
+ try {
1513
+ finalFlush = { flushed: await t.endRun(), flush: t.lastFlush };
1514
+ } catch (e) {
1515
+ const started = t.lastFlush ?? { linkAt: null, resentAt: null, ackAt: null, gaveUpAt: null };
1516
+ finalFlush = { flushed: FLUSH_END_RUN_FAILED, flush: { ...started, gaveUpAt: deps.now(), error: e instanceof Error ? e.message : String(e) } };
1517
+ }
1518
+ progress();
1519
+ await setSessionStatus(!!error, error ? error.message.slice(0, 250) : `${sessionName} done`);
1520
+ }
1521
+ const flushedAs = (flushStatus, tokenOnly = false) => [
1522
+ ...iterations.filter((r) => r.flushed === flushStatus && (!tokenOnly || r.token)).map((r) => `${r.journey} #${r.iteration}`),
1523
+ ...finalFlush?.flushed === flushStatus && (!tokenOnly || iterations.some((r) => r.token)) ? ["final flush"] : []
1524
+ ];
1525
+ flushNotAcknowledged = [...flushedAs(FLUSH_NOT_ACKNOWLEDGED), ...flushedAs(FLUSH_UNREADABLE), ...flushedAs(FLUSH_END_RUN_FAILED)];
1526
+ flushError = flushedAs(FLUSH_ERROR);
1527
+ flushOff = flushedAs(FLUSH_OFF, true);
1528
+ if (flushNotAcknowledged.length)
1529
+ deps.error(`::error::flush not acknowledged (no answer after the re-send, an unreadable answer, or a failed end-run): ${flushNotAcknowledged.join(", ")}`);
1530
+ if (flushError.length)
1531
+ deps.error(`::error::flush answered error (data not uploaded): ${flushError.join(", ")}`);
1532
+ if (flushOff.length)
1533
+ deps.error(`::error::flush answered off in a run with a token (upload not configured, nothing uploaded): ${flushOff.join(", ")}`);
1534
+ endedAt = deps.now();
1535
+ await driver.end();
1536
+ await readDetail();
1537
+ const failed = !!(error || flushNotAcknowledged.length || flushError.length || flushOff.length);
1538
+ if (terminating)
1539
+ return EXIT_CODE[signal ?? "SIGTERM"];
1540
+ writeBuild(error?.message ?? null);
1541
+ status = failed ? "error" : "done";
1542
+ persist();
1543
+ deps.print(`driver-log.json: ${log.length} commands, ${iterations.length} iterations, ${Math.round((endedAt - startedAt) / 1000)} s`);
1544
+ for (const r of iterations)
1545
+ deps.print(` ${r.journey} #${r.iteration}: ${r.total_ms ?? "?"} ms, flushed=${r.flushed}`);
1546
+ return failed ? 1 : 0;
1547
+ } catch (e) {
1548
+ if (!terminating) {
1549
+ status = "error";
1550
+ error ??= e instanceof Error ? e : new Error(String(e));
1551
+ endedAt = deps.now();
1552
+ progress();
1553
+ }
1554
+ throw e;
1555
+ } finally {
1556
+ for (const [sig, handler] of handlers)
1557
+ proc?.off(sig, handler);
1558
+ }
1303
1559
  }
1304
- async function runIterations(t, module, ctx, iterations) {
1560
+ async function runIterations(t, module, ctx, iterations, progress) {
1305
1561
  const { driver } = t;
1306
1562
  for (const it of plan(ctx.journeys, ctx.repeat, ctx.tokens)) {
1307
1563
  const rec = { journey: it.journey, iteration: it.iteration, token: it.token !== "" };
1308
1564
  iterations.push(rec);
1565
+ progress();
1309
1566
  driver.context = { journey: it.journey, iteration: it.iteration, phase: "begin" };
1310
1567
  const t0 = t.now();
1311
1568
  rec.alert = await t.beginRun({ token: it.token, home: module.home, ...module.sdkArgs ? { args: module.sdkArgs } : {} });
@@ -1321,8 +1578,10 @@ async function runIterations(t, module, ctx, iterations) {
1321
1578
  rec.ready = { n: waits.length, ms: waits.reduce((x, y) => x + y, 0) };
1322
1579
  driver.context = { ...driver.context, phase: "end" };
1323
1580
  rec.flushed = await t.endRun();
1581
+ rec.flush = t.lastFlush;
1324
1582
  const t3 = t.now();
1325
1583
  Object.assign(rec, { t_start: t0, begin_ms: t1 - t0, journey_ms: t2 - t1, end_ms: t3 - t2, total_ms: t3 - t0 });
1584
+ progress();
1326
1585
  }
1327
1586
  }
1328
1587