@keepsnappy/agent 0.1.0-alpha.16 → 0.1.0-alpha.18
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 +8 -7
- package/dist/appium-driver.js +78 -9
- package/dist/keepsnappy.js +1951 -580
- package/examples/farm/workflows/keepsnappy-measure.yml +3 -1
- package/examples/github-actions.yml +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Annotated example: `examples/keepsnappy.yml`. Every setting:
|
|
|
70
70
|
|
|
71
71
|
Unknown settings and wrong types are errors, all listed together.
|
|
72
72
|
|
|
73
|
-
Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`)
|
|
73
|
+
Results and exit codes: results never change the exit code by default (ruling D9, no blocking PR checks): `0` also for "More work". `--fail-on-worse` (needs `--wait`) fails the job (exit `3`) only when the verdict computed on that run set is Worse and blocking. Closing a run set never computes its verdict (decision 0031: the operator computes verdicts on demand), so with no computed verdict `--fail-on-worse` checks nothing: the CLI prints `Verdict not computed yet (computed on demand): --fail-on-worse checked nothing` and exits `0`. The CLI exits `2` for a configuration problem (bad or missing `keepsnappy.yml`, unknown or misspelt flags, a glob matching nothing, `--wait` without a branch or key) and `1` for an infrastructure problem (cannot read the device log, an API request failing, an API URL that does not answer as the KeepSnappy API, `--upload` that sent no run: "Blocked"; results stay in the out dir). Failed runs are marked invalid in `runs.ndjson` / `metrics.csv` and ignored by the statistics. The ending summary reads like:
|
|
74
74
|
|
|
75
75
|
```
|
|
76
76
|
7/8 runs valid (dropped: 1 test-failed: exit 1)
|
|
@@ -137,7 +137,7 @@ runner. It writes:
|
|
|
137
137
|
run with data. A measured iteration sends nothing until its end-of-iteration flush, so a run with data completed and
|
|
138
138
|
is never blamed; a Maestro failure stops the session, so later iterations have no data;
|
|
139
139
|
3. finishes every run not yet finished (a run without a single batch is reported as "no data", never judged), closes the
|
|
140
|
-
set (`--wait`: verdicts inline; else on read)
|
|
140
|
+
set (`--wait`: verdicts inline; else on read). It then prints the iterations and the same plain-words verdict as
|
|
141
141
|
`keepsnappy run --upload --wait`, plus `$GITHUB_STEP_SUMMARY` and `<out>/keepsnappy-result.json` (`--out`, default the
|
|
142
142
|
state's directory);
|
|
143
143
|
4. puts the empty stub back into the tokens file, also when a step above failed. The example workflow also deletes the
|
|
@@ -145,13 +145,14 @@ runner. It writes:
|
|
|
145
145
|
|
|
146
146
|
Running it again is safe: finished runs are left alone, the set is not reprocessed, the same result is printed. A batch
|
|
147
147
|
that arrives after a run was finished is added by the server and the run re-processed at once. Exit codes: `0` for any
|
|
148
|
-
result, `3` with `--fail-on-worse`
|
|
148
|
+
result, `3` with `--fail-on-worse` when the verdict computed on that run set is Worse and blocking (with no computed
|
|
149
|
+
verdict it checks nothing and says so, exit `0`), `2` for configuration, `1` when the API fails or nothing could be
|
|
149
150
|
judged ("Blocked": no iteration sent any data).
|
|
150
151
|
|
|
151
|
-
Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close
|
|
152
|
-
result
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close` posts a measurement that sent
|
|
153
|
+
no data ("No result") at once. Results are posted after the verdict is computed, by the operator's recompute job (decision 0031), in
|
|
154
|
+
two lines and one button (docs/mockups/2026-10-26-slack, present `resultSlackPayload`). `$GITHUB_STEP_SUMMARY` gets the same two
|
|
155
|
+
lines: "No result", the computed result, or "Not judged yet · <branch> / Waiting for the judgement" with the run set's page. A Slack failure is printed and never changes the exit code.
|
|
155
156
|
|
|
156
157
|
Declared steps on the farm: `maestro/keepsnappy/step.yaml` / `step.js` / `send-markers.js` (copy them into the suite).
|
|
157
158
|
With a token set, `step.js` only records the marker; `send-markers.js`, run in `end-run.yaml` after each iteration,
|
package/dist/appium-driver.js
CHANGED
|
@@ -303,6 +303,10 @@ class WdError extends Error {
|
|
|
303
303
|
this.wdError = wdError;
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
+
var IOS_BOTTOM_INSET_PT = 34;
|
|
307
|
+
var GESTURE_ZONE_MARGIN_PT = 16;
|
|
308
|
+
var ANDROID_NAV_BAR_FALLBACK_DP = 48;
|
|
309
|
+
var GESTURE_ZONE_MARGIN_DP = 16;
|
|
306
310
|
|
|
307
311
|
class Driver {
|
|
308
312
|
bundleId;
|
|
@@ -317,6 +321,8 @@ class Driver {
|
|
|
317
321
|
deviceUnit;
|
|
318
322
|
context = {};
|
|
319
323
|
viewport = null;
|
|
324
|
+
pxPerDp = null;
|
|
325
|
+
bottomLimit = null;
|
|
320
326
|
rects = new Map;
|
|
321
327
|
constructor(o) {
|
|
322
328
|
this.bundleId = o.bundleId;
|
|
@@ -381,6 +387,29 @@ class Driver {
|
|
|
381
387
|
this.viewport ??= await this.cmd("GET", "/window/rect");
|
|
382
388
|
return this.viewport;
|
|
383
389
|
}
|
|
390
|
+
async density() {
|
|
391
|
+
if (this.pxPerDp === null) {
|
|
392
|
+
const dpi = Number(await this.execute("mobile: getDisplayDensity"));
|
|
393
|
+
if (!(dpi > 0))
|
|
394
|
+
throw new Error(`mobile: getDisplayDensity answered ${dpi}, not a density`);
|
|
395
|
+
this.pxPerDp = dpi / 160;
|
|
396
|
+
}
|
|
397
|
+
return this.pxPerDp;
|
|
398
|
+
}
|
|
399
|
+
async safeBottomY() {
|
|
400
|
+
if (this.bottomLimit === null) {
|
|
401
|
+
const { height } = await this.screen();
|
|
402
|
+
if (this.platform.name === "ios") {
|
|
403
|
+
this.bottomLimit = height - IOS_BOTTOM_INSET_PT - GESTURE_ZONE_MARGIN_PT;
|
|
404
|
+
} else {
|
|
405
|
+
const dp = await this.density();
|
|
406
|
+
const bar = await this.execute("mobile: getSystemBars").then((b) => Number(b?.navigationBar?.height), () => NaN);
|
|
407
|
+
const inset = bar > 0 ? bar : ANDROID_NAV_BAR_FALLBACK_DP * dp;
|
|
408
|
+
this.bottomLimit = Math.round(height - inset - GESTURE_ZONE_MARGIN_DP * dp);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return this.bottomLimit;
|
|
412
|
+
}
|
|
384
413
|
async onScreen(el, full = false) {
|
|
385
414
|
let r;
|
|
386
415
|
try {
|
|
@@ -479,6 +508,7 @@ class Driver {
|
|
|
479
508
|
await this.cmd("DELETE", "/actions").catch(() => null);
|
|
480
509
|
}
|
|
481
510
|
async drag(from, to, durationMs) {
|
|
511
|
+
to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
|
|
482
512
|
await this.cmd("POST", "/actions", {
|
|
483
513
|
actions: [
|
|
484
514
|
{
|
|
@@ -676,7 +706,10 @@ function sessionCaps(i) {
|
|
|
676
706
|
sessionName: i.sessionName,
|
|
677
707
|
...tunnel,
|
|
678
708
|
idleTimeout: 300,
|
|
679
|
-
appiumVersion: i.appiumVersion
|
|
709
|
+
appiumVersion: i.appiumVersion,
|
|
710
|
+
video: i.capture ?? false,
|
|
711
|
+
deviceLogs: i.capture ?? false,
|
|
712
|
+
networkLogs: false
|
|
680
713
|
}
|
|
681
714
|
};
|
|
682
715
|
}
|
|
@@ -693,7 +726,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
693
726
|
import { fileURLToPath } from "node:url";
|
|
694
727
|
var DRIVER_NAME = "appium";
|
|
695
728
|
function driverVersion() {
|
|
696
|
-
const built = "0.1.0-alpha.
|
|
729
|
+
const built = "0.1.0-alpha.18";
|
|
697
730
|
if (built)
|
|
698
731
|
return built;
|
|
699
732
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -767,7 +800,7 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
|
|
|
767
800
|
const url = `${base}/v1/markers`;
|
|
768
801
|
const version = (opts.driverVersion ?? driverVersion)();
|
|
769
802
|
const reported = opts.reported ?? new Set;
|
|
770
|
-
const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {} } };
|
|
803
|
+
const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {}, ...opts.capture ? { capture: opts.capture } : {} } };
|
|
771
804
|
let sent = 0;
|
|
772
805
|
for (const [token, list] of byToken) {
|
|
773
806
|
const trips = [];
|
|
@@ -824,6 +857,26 @@ var FLUSH_POLL_MS = 500;
|
|
|
824
857
|
var FLUSH_RESEND_MS = 90000;
|
|
825
858
|
var ACTIVATION_SETTLE_MS = 2000;
|
|
826
859
|
var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
|
|
860
|
+
var flushLoss = (detail) => {
|
|
861
|
+
if (!detail)
|
|
862
|
+
return "no detail";
|
|
863
|
+
const count = (re) => Number(re.exec(detail)?.[1] ?? 0);
|
|
864
|
+
const lost = [];
|
|
865
|
+
const rejected = count(/^rejected \d+: (\d+) batch/);
|
|
866
|
+
if (!detail.startsWith("batches=") && !rejected && !detail.startsWith("lines of the run dropped before sending")) {
|
|
867
|
+
lost.push(`lines not sent (${detail.replace(/ (?:dropped|artifacts)=.*$/, "")})`);
|
|
868
|
+
}
|
|
869
|
+
if (count(/ dropped=(\d+)/))
|
|
870
|
+
lost.push(`lines dropped=${count(/ dropped=(\d+)/)}`);
|
|
871
|
+
if (rejected)
|
|
872
|
+
lost.push(`line batches rejected=${rejected}`);
|
|
873
|
+
const files = /failed=(\d+) pending=(\d+) local=(\d+) rejected=(\d+)/.exec(detail);
|
|
874
|
+
if (files)
|
|
875
|
+
lost.push(`profile files not uploaded=${files.slice(1).reduce((n, x) => n + Number(x), 0)}`);
|
|
876
|
+
if (count(/ lost stream=(\d+)/))
|
|
877
|
+
lost.push(`stream segments lost=${count(/ lost stream=(\d+)/)}`);
|
|
878
|
+
return lost.length ? lost.join(", ") : detail;
|
|
879
|
+
};
|
|
827
880
|
var FLUSH_UNREADABLE = "not acknowledged (unreadable answer)";
|
|
828
881
|
var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
|
|
829
882
|
var UNREADABLE_POLLS = 3;
|
|
@@ -849,9 +902,11 @@ class Session {
|
|
|
849
902
|
readyWaits = [];
|
|
850
903
|
actionGc;
|
|
851
904
|
endGc;
|
|
905
|
+
capture;
|
|
852
906
|
gcN = 0;
|
|
853
907
|
stepDepth = 0;
|
|
854
908
|
stepLog = [];
|
|
909
|
+
defects = { gestureRetry: 0, items: [] };
|
|
855
910
|
onStepChange = null;
|
|
856
911
|
refinds = 0;
|
|
857
912
|
get staleRefinds() {
|
|
@@ -864,6 +919,7 @@ class Session {
|
|
|
864
919
|
this.api = o.api ?? "";
|
|
865
920
|
this.actionGc = o.actionGc ? "step" : "off";
|
|
866
921
|
this.endGc = o.endGc ?? END_GC_DEFAULT;
|
|
922
|
+
this.capture = o.capture;
|
|
867
923
|
}
|
|
868
924
|
async gc(atEnd = false) {
|
|
869
925
|
if (this.actionGc !== "step" && !(atEnd && this.endGc && this.actionGc === "off"))
|
|
@@ -1240,7 +1296,7 @@ class Session {
|
|
|
1240
1296
|
async endRun() {
|
|
1241
1297
|
if (this.markers.some((m) => m.phase === "start"))
|
|
1242
1298
|
await this.memRead(await this.gc(true));
|
|
1243
|
-
await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, 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 }) });
|
|
1299
|
+
await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, capture: this.capture, tokens: [...this.opened], reported: this.told, note: (line) => this.driver.log.push({ ...this.driver.context, cmd: "driver-report", t: this.now(), ms: 0, status: 1, answer: line }) });
|
|
1244
1300
|
const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
|
|
1245
1301
|
this.lastFlush = evidence;
|
|
1246
1302
|
const acks = new Set;
|
|
@@ -1357,7 +1413,8 @@ var DRIVER_OPTIONS = {
|
|
|
1357
1413
|
out: { type: "string", default: "farm-run" },
|
|
1358
1414
|
platform: { type: "string", default: "ios" },
|
|
1359
1415
|
"action-gc": { type: "string", default: "off" },
|
|
1360
|
-
"end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" }
|
|
1416
|
+
"end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" },
|
|
1417
|
+
capture: { type: "string", default: "off" }
|
|
1361
1418
|
};
|
|
1362
1419
|
function parseDriverArgs(argv, module) {
|
|
1363
1420
|
const values = parseArgs({ args: [...argv], options: { ...module.options ?? {}, ...DRIVER_OPTIONS } }).values;
|
|
@@ -1397,13 +1454,16 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1397
1454
|
throw new Error("--action-gc must be on or off");
|
|
1398
1455
|
if (a["end-gc"] !== "on" && a["end-gc"] !== "off")
|
|
1399
1456
|
throw new Error("--end-gc must be on or off");
|
|
1457
|
+
if (a.capture !== "on" && a.capture !== "off")
|
|
1458
|
+
throw new Error("--capture must be on or off");
|
|
1459
|
+
const capture = a.capture === "on";
|
|
1400
1460
|
const out = String(a.out);
|
|
1401
1461
|
const auth = basicAuth(user, key);
|
|
1402
1462
|
mkdirSync(out, { recursive: true });
|
|
1403
1463
|
const { api, tokens } = parseTokensJs(readFileSync2(String(a["tokens-js"]), "utf8"));
|
|
1404
1464
|
const log = [];
|
|
1405
1465
|
const driver = new Driver({ hub: HUB, log, headers: { Authorization: auth }, bundleId: module.app.bundleId, platform, fetch: deps.fetch, now: deps.now, sleep: deps.sleep });
|
|
1406
|
-
const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
|
|
1466
|
+
const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on", capture: capture ? "on" : "off" });
|
|
1407
1467
|
const sessionName = module.sessionName ?? "keepsnappy (appium)";
|
|
1408
1468
|
const device = String(a.device);
|
|
1409
1469
|
const ctx = { fetch: deps.fetch, tokens, journeys, repeat, args: a };
|
|
@@ -1427,6 +1487,8 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1427
1487
|
driver: "appium",
|
|
1428
1488
|
status,
|
|
1429
1489
|
device,
|
|
1490
|
+
unit: driver.deviceUnit ?? null,
|
|
1491
|
+
capture,
|
|
1430
1492
|
appUrl,
|
|
1431
1493
|
session: driver.sessionId,
|
|
1432
1494
|
buildId,
|
|
@@ -1444,6 +1506,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1444
1506
|
flushError,
|
|
1445
1507
|
flushOff,
|
|
1446
1508
|
finalFlush,
|
|
1509
|
+
defects: t.defects,
|
|
1447
1510
|
steps: t.stepLog,
|
|
1448
1511
|
iterations,
|
|
1449
1512
|
commands: log
|
|
@@ -1531,13 +1594,14 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1531
1594
|
try {
|
|
1532
1595
|
appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
|
|
1533
1596
|
deps.print(`app: ${appUrl}`);
|
|
1534
|
-
const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"] });
|
|
1597
|
+
const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"], capture });
|
|
1535
1598
|
startedAt = deps.now();
|
|
1536
1599
|
try {
|
|
1537
1600
|
await driver.start(caps);
|
|
1538
1601
|
deps.print(`session ${driver.sessionId}`);
|
|
1539
1602
|
progress();
|
|
1540
1603
|
await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
|
|
1604
|
+
await driver.safeBottomY().catch(() => null);
|
|
1541
1605
|
await module.setup?.(t, ctx);
|
|
1542
1606
|
await runIterations(t, module, ctx, iterations, progress);
|
|
1543
1607
|
} catch (e) {
|
|
@@ -1569,8 +1633,13 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1569
1633
|
flushOff = flushedAs(FLUSH_OFF, true);
|
|
1570
1634
|
if (flushNotAcknowledged.length)
|
|
1571
1635
|
deps.error(`::error::flush not acknowledged (no answer after the re-send, an unreadable answer, or a failed end-run): ${flushNotAcknowledged.join(", ")}`);
|
|
1572
|
-
if (flushError.length)
|
|
1573
|
-
|
|
1636
|
+
if (flushError.length) {
|
|
1637
|
+
const losses = [
|
|
1638
|
+
...iterations.filter((r) => r.flushed === FLUSH_ERROR).map((r) => `${r.journey} #${r.iteration} (${flushLoss(r.flush?.detail)})`),
|
|
1639
|
+
...finalFlush?.flushed === FLUSH_ERROR ? [`final flush (${flushLoss(finalFlush.flush?.detail)})`] : []
|
|
1640
|
+
];
|
|
1641
|
+
deps.error(`::error::flush answered error, data lost: ${losses.join(", ")}`);
|
|
1642
|
+
}
|
|
1574
1643
|
if (flushOff.length)
|
|
1575
1644
|
deps.error(`::error::flush answered off in a run with a token (upload not configured, nothing uploaded): ${flushOff.join(", ")}`);
|
|
1576
1645
|
endedAt = deps.now();
|