@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.
- package/README.md +14 -16
- package/dist/appium-driver.js +340 -81
- package/dist/keepsnappy.js +425 -876
- package/examples/farm/flows/keepsnappy/end-run.yaml +4 -3
- package/examples/keepsnappy.yml +1 -3
- package/package.json +1 -1
package/dist/appium-driver.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1159
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
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:
|
|
1424
|
+
status: failure === null ? "passed" : "failed",
|
|
1292
1425
|
buildId,
|
|
1293
|
-
dashboard
|
|
1294
|
-
devices: [{ device, sessions: [{ id: driver.sessionId, ...
|
|
1426
|
+
dashboard,
|
|
1427
|
+
devices: [{ device, sessions: [{ id: driver.sessionId, ...failure === null ? {} : { error: { message: failure } } }] }]
|
|
1295
1428
|
}, null, 2)}
|
|
1296
1429
|
`);
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
|