@keepsnappy/agent 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +36 -6
- package/dist/appium-driver.js +124 -17
- package/dist/keepsnappy.js +1924 -373
- 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)
|
|
@@ -103,10 +103,12 @@ keepsnappy farm open --api https://keepsnappy-api-….run.app --branch main --co
|
|
|
103
103
|
keepsnappy farm close --state farm-run/keepsnappy-farm.json --bs-build farm-run/browserstack.ios.json --settle-seconds 60 --wait
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
`keepsnappy farm run` does the whole night in one command (docs/plans/54a): resolve the app (`--app eas:<id>|file|bs
|
|
106
|
+
`keepsnappy farm run` does the whole night in one command (docs/plans/54a): resolve the app (`--app eas:<id>|file|bs://<id>|custom_id:<id>`), `farm open`, an optional BrowserStack Local tunnel (`--tunnel-for localhost:<port>`, repeatable), the device run (`--driver maestro` with `--suite-dir`/`--execute`, or `--driver appium --journey-module FILE` for the built-in Appium driver on iOS or Android, `@keepsnappy/appium-driver`, docs/plans/54b; `--driver-script FILE` still runs your own driver), `farm close`, and cleanup (tunnel stopped, tokens file reset) whatever happened. `--env KEY=VALUE` values may use `{tunnel:<port>}`, `{run_id}`, `{out}`, `{tokens_js}`, `{run_set_id}`. Pre-uploaded `bs://` and `custom_id:` app references pass through without a file upload. Appium uses `--timeout-minutes 80` by default: the driver receives SIGTERM at the timeout, then SIGKILL after `--kill-after-seconds 120` if it has not exited. A timeout is recorded in the failure message, including the terminating signal, and returns exit 1 even if the driver exits zero after SIGTERM. Exit codes as `farm close`; a failed device run is 1. `--dry-run` prints the plan with no request. Needs `KEEPSNAPPY_KEY`, `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` (and `EXPO_TOKEN` for `eas:`).
|
|
107
107
|
|
|
108
108
|
Setup and completion (plan 51 stage 2): `--completion-marker` declares that the flow posts the step marker `run:complete` (end) at the end of every iteration, from the main flow and not from `onFlowComplete`; the API then marks a run without it invalid (`flow-incomplete`). `--driver-version`, `--app-build-id`, `--harness-version`, `--flag-set` go into every run's setup fingerprint; `farm close` adds the device farm's build id from `--bs-build`.
|
|
109
109
|
|
|
110
|
+
The model a phone is stored under is the hardware code the OS reports (`SM-S911B`, `iPhone17,1`); the marketing name is only shown. With `--platform android|ios`, `--device-provider local|usb|customer|simulator|emulator` (the default `browserstack` needs an explicit model: a farm's phone is not on this machine) and no `--device-model`, `farm open` reads it from the device: Android `adb [-s <serial>] shell getprop ro.product.model`, an iPhone's machine id from `xcrun devicectl list devices`, a simulator's name from `xcrun simctl list devices` (kept with its "Simulator" word, so it never joins a real phone's row). With `--platform ios` and a `--device-id` that `xcrun simctl` lists as a simulator, `farm open` opens the run set as a simulator whatever provider and model were typed: provider `simulator`, the model with the "Simulator" word. It reads nothing it cannot pin to exactly one device (`--device-id` picks one); then `--device-model` is required. A typed `--device-model` is for a device this machine cannot ask (a farm's phone) and is sent as given.
|
|
111
|
+
|
|
110
112
|
`farm open` also sends `journeys[]`: with `--config keepsnappy.yml` and/or `--flows <files|dirs|globs,...>` each `--journeys` name is matched to the flow file named like it (title, description, source path as for `keepsnappy run`); without them, keys only.
|
|
111
113
|
|
|
112
114
|
`farm open` makes a run set (`source: farm`, the device of `--device-model/--device-os`: the journey history is keyed by
|
|
@@ -143,7 +145,8 @@ runner. It writes:
|
|
|
143
145
|
|
|
144
146
|
Running it again is safe: finished runs are left alone, the set is not reprocessed, the same result is printed. A batch
|
|
145
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
|
|
146
|
-
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
|
|
147
150
|
judged ("Blocked": no iteration sent any data).
|
|
148
151
|
|
|
149
152
|
Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close --wait` posts the
|
|
@@ -176,25 +179,31 @@ same words (`packages/mcp/README.md`).
|
|
|
176
179
|
```sh
|
|
177
180
|
export KEEPSNAPPY_KEY=pl_rk_… # a project key with read scope (a CI key with run scope works too)
|
|
178
181
|
keepsnappy findings --branch feat/keepsnappy-integration # what got worse or better on a PR, worst first
|
|
182
|
+
keepsnappy status --branch feat/keepsnappy-integration # is my PR ok? one answer: blocked / nothing blocks / ok, per journey × phone, ids of the worst findings, links
|
|
179
183
|
keepsnappy findings --main --journey live-ui --phone "iPhone 15"
|
|
180
184
|
keepsnappy findings --state all --json # also the resolved and not judged ones, as the API's JSON
|
|
181
185
|
keepsnappy findings --state all --offset 200 # the next page (at most 200 per answer)
|
|
182
186
|
keepsnappy finding f_0123456789abcdef # one finding with its evidence
|
|
183
187
|
keepsnappy finding f_0123456789abcdef --prompt | pbcopy # only the hand-off for a coding agent, ending with its id and page
|
|
184
188
|
keepsnappy compare <runA> <runB> # two runs (ids or external ids), window by window, no statistics
|
|
185
|
-
keepsnappy history live-ui
|
|
189
|
+
keepsnappy history live-ui press:live.chat.send # one window on main, run by run (exact place id)
|
|
186
190
|
keepsnappy commits a1b2c3d c3d4e5f [--branch main] # the measured commits between two points
|
|
191
|
+
keepsnappy places --journey live-ui --phone "iPhone 15" # every place of the newest main session from its stored statistics, no verdict
|
|
192
|
+
keepsnappy places --run-set <id> --measure commit_max_ms --sort p90 --limit 10
|
|
187
193
|
keepsnappy layers # which layer answers which question, and its certificate
|
|
194
|
+
keepsnappy findings --branch feat/x --short # the short form: what decides, no evidence detail (also status, finding, history, places, main, trend)
|
|
195
|
+
keepsnappy trend live-ui Tap: live chat send --phone "iPhone 15" # the words after the journey are one window name; more windows with repeated --window
|
|
188
196
|
```
|
|
189
197
|
|
|
190
|
-
Flags: `--api URL` (or `KEEPSNAPPY_API`, default the hosted API), `--key-env NAME` (default `KEEPSNAPPY_KEY`), `--json`.
|
|
198
|
+
Flags: `--api URL` (or `KEEPSNAPPY_API`, default the hosted API), `--key-env NAME` (default `KEEPSNAPPY_KEY`), `--json`, `--short`.
|
|
199
|
+
`--short` prints the same text with the evidence detail left out (the MCP tools' `form: "short"`); its last line says what was left out, and the MCP tool descriptions list it per answer. `--json` is the same with or without it.
|
|
191
200
|
A finding's id is stable: the same window, measure, journey and phone model keep the id across commits and PRs, so
|
|
192
201
|
`keepsnappy finding <id>` after a fix tells whether it is resolved. `--state` (default `present`) filters on the state on
|
|
193
202
|
the asked branch: `present` (in the newest run set), `resolved` (the newest run set judged the measure: No change, or the
|
|
194
203
|
other way), `not-judged` (the newest run set did not judge it: not measured, unstable, not enough data; it may still be
|
|
195
204
|
there). A list starts with whether and when the branch was measured ("No KeepSnappy measurement on branch X", or the
|
|
196
205
|
newest measured commit, its word and runs), so an empty list on an unmeasured branch does not read as clean. `commits`
|
|
197
|
-
lists each measured commit once, with how many sessions measured it. Words come from the one word table (Worse / Warning / Better …), "Worse (not blocking)" when the
|
|
206
|
+
lists each measured commit once, with how many sessions measured it. `places` lists the places of one session (a run set, or the newest closed one of a journey on a phone and branch) with each measure's median, spread and the components with the most own time, ranked by `--measure` and `--sort`; a session without stored statistics says `not computed`, never zero. Words come from the one word table (Worse / Warning / Better …), "Worse (not blocking)" when the
|
|
198
207
|
branch was measured differently from main.
|
|
199
208
|
|
|
200
209
|
## Output layout
|
|
@@ -257,3 +266,24 @@ Memory slopes (Theil-Sen over the cycle index, 3+ cycles of `--mem-window`, defa
|
|
|
257
266
|
verdicts, profile-analysis, mcp), so both work from a clean checkout.
|
|
258
267
|
|
|
259
268
|
Only `keepsnappy` and `keepsnappy.yml` / `keepsnappy.yaml` are supported. Configure `KEEPSNAPPY_*` variables. Explicit `--key-env`, `project.key_env`, and `--config` remain arbitrary user-supplied names and paths.
|
|
269
|
+
|
|
270
|
+
### Source maps from an EAS build
|
|
271
|
+
|
|
272
|
+
In an `eas-build-on-success` hook, run:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
npx @keepsnappy/agent sourcemaps upload --eas
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`--eas` runs only when `EAS_BUILD_PROFILE=e2e`. It uses `EAS_BUILD_GIT_COMMIT_HASH` as the build commit and `EAS_BUILD_PLATFORM` to select the map relative to the working directory:
|
|
279
|
+
|
|
280
|
+
| Platform | Map | Kind |
|
|
281
|
+
| --- | --- | --- |
|
|
282
|
+
| `ios` | `main.jsbundle.packager.map` | `packager` |
|
|
283
|
+
| `android` | `android/app/build/generated/sourcemaps/react/release/index.android.bundle.map` | `hermes` |
|
|
284
|
+
|
|
285
|
+
The iOS packager map preserves the positions used by CPU profiles; Android profiles use the composed Hermes map. The upload flavour defaults to `e2e`.
|
|
286
|
+
|
|
287
|
+
Set `KEEPSNAPPY_KEY` or `KEEPSNAPPY_CI_KEY` and `KEEPSNAPPY_API` or `EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL`. The usual key/API names take precedence over the EAS hook names. Explicit `--key-env`, `--api`/`--ingest-url`, `--build`, `--platform`, `--kind`, `--flavour` and map files override these defaults. An explicit `--key-env` uses only that variable.
|
|
288
|
+
|
|
289
|
+
A missing key, API URL, commit or map, an unsupported platform, or a profile other than `e2e` produces one skip line and exits 0. There is no default API URL in EAS mode. Upload, map parsing and argument failures also exit 0 with a generic failure line so the application build stays successful and error details cannot expose credentials. Ordinary `sourcemaps upload` keeps its existing validation and failure behavior. Source text (`sourcesContent`) is stripped before upload in both modes.
|
package/dist/appium-driver.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// packages/appium-driver/src/load.ts
|
|
4
|
-
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { isAbsolute, resolve as resolve2 } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
7
|
// packages/appium-driver/src/run.ts
|
|
8
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
8
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join as join2 } from "node:path";
|
|
10
10
|
import { parseArgs } from "node:util";
|
|
11
11
|
|
|
12
12
|
// packages/appium-driver/src/browserstack.ts
|
|
@@ -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;
|
|
@@ -314,8 +318,11 @@ class Driver {
|
|
|
314
318
|
now;
|
|
315
319
|
sleep;
|
|
316
320
|
sessionId = null;
|
|
321
|
+
deviceUnit;
|
|
317
322
|
context = {};
|
|
318
323
|
viewport = null;
|
|
324
|
+
pxPerDp = null;
|
|
325
|
+
bottomLimit = null;
|
|
319
326
|
rects = new Map;
|
|
320
327
|
constructor(o) {
|
|
321
328
|
this.bundleId = o.bundleId;
|
|
@@ -355,6 +362,7 @@ class Driver {
|
|
|
355
362
|
async start(capabilities) {
|
|
356
363
|
const value = await this.cmd("POST", "/session", { capabilities: { alwaysMatch: capabilities } });
|
|
357
364
|
this.sessionId = value.sessionId;
|
|
365
|
+
this.deviceUnit = [value.capabilities?.udid, value.capabilities?.deviceUDID].find((unit) => typeof unit === "string" && unit.length > 0);
|
|
358
366
|
return value;
|
|
359
367
|
}
|
|
360
368
|
execute(script, arg) {
|
|
@@ -379,6 +387,29 @@ class Driver {
|
|
|
379
387
|
this.viewport ??= await this.cmd("GET", "/window/rect");
|
|
380
388
|
return this.viewport;
|
|
381
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
|
+
}
|
|
382
413
|
async onScreen(el, full = false) {
|
|
383
414
|
let r;
|
|
384
415
|
try {
|
|
@@ -477,6 +508,7 @@ class Driver {
|
|
|
477
508
|
await this.cmd("DELETE", "/actions").catch(() => null);
|
|
478
509
|
}
|
|
479
510
|
async drag(from, to, durationMs) {
|
|
511
|
+
to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
|
|
480
512
|
await this.cmd("POST", "/actions", {
|
|
481
513
|
actions: [
|
|
482
514
|
{
|
|
@@ -641,7 +673,7 @@ var HUB = "https://hub-cloud.browserstack.com/wd/hub";
|
|
|
641
673
|
var BS_API = "https://api-cloud.browserstack.com/app-automate";
|
|
642
674
|
var basicAuth = (user, key) => `Basic ${Buffer.from(`${user}:${key}`).toString("base64")}`;
|
|
643
675
|
async function resolveApp(app, customId, auth, fetchImpl, platform = IOS) {
|
|
644
|
-
if (app.startsWith("bs://"))
|
|
676
|
+
if (app.startsWith("bs://") || app.startsWith("custom_id:"))
|
|
645
677
|
return app;
|
|
646
678
|
const headers = { Authorization: auth };
|
|
647
679
|
if (customId) {
|
|
@@ -674,7 +706,10 @@ function sessionCaps(i) {
|
|
|
674
706
|
sessionName: i.sessionName,
|
|
675
707
|
...tunnel,
|
|
676
708
|
idleTimeout: 300,
|
|
677
|
-
appiumVersion: i.appiumVersion
|
|
709
|
+
appiumVersion: i.appiumVersion,
|
|
710
|
+
video: i.capture ?? false,
|
|
711
|
+
deviceLogs: i.capture ?? false,
|
|
712
|
+
networkLogs: false
|
|
678
713
|
}
|
|
679
714
|
};
|
|
680
715
|
}
|
|
@@ -685,6 +720,29 @@ async function sessionDetail(sessionId, auth, fetchImpl) {
|
|
|
685
720
|
return (await res.json()).automation_session ?? {};
|
|
686
721
|
}
|
|
687
722
|
|
|
723
|
+
// packages/appium-driver/src/version.ts
|
|
724
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
725
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
726
|
+
import { fileURLToPath } from "node:url";
|
|
727
|
+
var DRIVER_NAME = "appium";
|
|
728
|
+
function driverVersion() {
|
|
729
|
+
const built = "0.1.0-alpha.17";
|
|
730
|
+
if (built)
|
|
731
|
+
return built;
|
|
732
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
733
|
+
for (let i = 0;i < 3; i++, dir = dirname(dir)) {
|
|
734
|
+
const file = join(dir, "package.json");
|
|
735
|
+
if (!existsSync(file))
|
|
736
|
+
continue;
|
|
737
|
+
try {
|
|
738
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
739
|
+
if (pkg.name === "@keepsnappy/appium-driver" && pkg.version)
|
|
740
|
+
return pkg.version;
|
|
741
|
+
} catch {}
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
|
|
688
746
|
// packages/appium-driver/src/session.ts
|
|
689
747
|
var MARKERS_PER_REQUEST = 500;
|
|
690
748
|
var MARKER_TRIES = 3;
|
|
@@ -740,6 +798,9 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
|
|
|
740
798
|
}
|
|
741
799
|
const base = api.replace(/\/+$/, "");
|
|
742
800
|
const url = `${base}/v1/markers`;
|
|
801
|
+
const version = (opts.driverVersion ?? driverVersion)();
|
|
802
|
+
const reported = opts.reported ?? new Set;
|
|
803
|
+
const driverField = { driver: { name: DRIVER_NAME, ...version ? { version } : {}, ...opts.deviceUnit ? { unit: opts.deviceUnit.slice(0, 200) } : {} } };
|
|
743
804
|
let sent = 0;
|
|
744
805
|
for (const [token, list] of byToken) {
|
|
745
806
|
const trips = [];
|
|
@@ -748,7 +809,9 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
|
|
|
748
809
|
return carried.length ? { ...body, clock: carried } : body;
|
|
749
810
|
};
|
|
750
811
|
const send = async (body) => {
|
|
751
|
-
const answer = await postWithRetry(fetchImpl, url, token, withClock(body), sleep, now);
|
|
812
|
+
const answer = await postWithRetry(fetchImpl, url, token, { ...withClock(body), ...reported.has(token) ? {} : driverField }, sleep, now);
|
|
813
|
+
if (answer && answer.status < 300)
|
|
814
|
+
reported.add(token);
|
|
752
815
|
if (answer?.trip)
|
|
753
816
|
trips.push(answer.trip);
|
|
754
817
|
return answer;
|
|
@@ -777,6 +840,14 @@ async function sendMarkers(fetchImpl, api, markers, opts = {}) {
|
|
|
777
840
|
if (trips.length)
|
|
778
841
|
await post(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: trips.splice(0) }, now);
|
|
779
842
|
}
|
|
843
|
+
for (const token of opts.tokens ?? []) {
|
|
844
|
+
if (byToken.has(token) || reported.has(token) || !token || !api)
|
|
845
|
+
continue;
|
|
846
|
+
const answer = await postWithRetry(fetchImpl, `${base}/v1/clock`, token, { source: "host", samples: [], ...driverField }, sleep, now);
|
|
847
|
+
reported.add(token);
|
|
848
|
+
if (!answer || answer.status >= 300)
|
|
849
|
+
opts.note?.(`driver report for a run not accepted (${answer?.status ?? "no answer"}); not tried again`);
|
|
850
|
+
}
|
|
780
851
|
return sent;
|
|
781
852
|
}
|
|
782
853
|
var SDK_FLUSH_WORST_CASE_MS = 315000;
|
|
@@ -786,6 +857,26 @@ var FLUSH_POLL_MS = 500;
|
|
|
786
857
|
var FLUSH_RESEND_MS = 90000;
|
|
787
858
|
var ACTIVATION_SETTLE_MS = 2000;
|
|
788
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
|
+
};
|
|
789
880
|
var FLUSH_UNREADABLE = "not acknowledged (unreadable answer)";
|
|
790
881
|
var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
|
|
791
882
|
var UNREADABLE_POLLS = 3;
|
|
@@ -1186,6 +1277,8 @@ class Session {
|
|
|
1186
1277
|
}
|
|
1187
1278
|
async beginRun({ token, args = "", home }) {
|
|
1188
1279
|
this.token = token;
|
|
1280
|
+
if (token)
|
|
1281
|
+
this.opened.add(token);
|
|
1189
1282
|
this.pendingReady = null;
|
|
1190
1283
|
await this.relaunch();
|
|
1191
1284
|
await this.waitVisible(home, 30000);
|
|
@@ -1193,12 +1286,14 @@ class Session {
|
|
|
1193
1286
|
await this.driver.sleep(ACTIVATION_SETTLE_MS);
|
|
1194
1287
|
return alert;
|
|
1195
1288
|
}
|
|
1289
|
+
opened = new Set;
|
|
1290
|
+
told = new Set;
|
|
1196
1291
|
lastFlush = null;
|
|
1197
1292
|
flushSeq = 0;
|
|
1198
1293
|
async endRun() {
|
|
1199
1294
|
if (this.markers.some((m) => m.phase === "start"))
|
|
1200
1295
|
await this.memRead(await this.gc(true));
|
|
1201
|
-
await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now() });
|
|
1296
|
+
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 }) });
|
|
1202
1297
|
const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
|
|
1203
1298
|
this.lastFlush = evidence;
|
|
1204
1299
|
const acks = new Set;
|
|
@@ -1315,7 +1410,8 @@ var DRIVER_OPTIONS = {
|
|
|
1315
1410
|
out: { type: "string", default: "farm-run" },
|
|
1316
1411
|
platform: { type: "string", default: "ios" },
|
|
1317
1412
|
"action-gc": { type: "string", default: "off" },
|
|
1318
|
-
"end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" }
|
|
1413
|
+
"end-gc": { type: "string", default: END_GC_DEFAULT ? "on" : "off" },
|
|
1414
|
+
capture: { type: "string", default: "off" }
|
|
1319
1415
|
};
|
|
1320
1416
|
function parseDriverArgs(argv, module) {
|
|
1321
1417
|
const values = parseArgs({ args: [...argv], options: { ...module.options ?? {}, ...DRIVER_OPTIONS } }).values;
|
|
@@ -1331,8 +1427,8 @@ function writeAtomic(file, text) {
|
|
|
1331
1427
|
renameSync(tmp, file);
|
|
1332
1428
|
}
|
|
1333
1429
|
async function captureFailure(driver, out) {
|
|
1334
|
-
const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(
|
|
1335
|
-
const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(
|
|
1430
|
+
const source = driver.cmd("GET", "/source").then((s) => void (typeof s === "string" && writeFileSync(join2(out, "failure-source.xml"), s)), () => null);
|
|
1431
|
+
const screen = driver.cmd("GET", "/screenshot").then((png) => void (typeof png === "string" && writeFileSync(join2(out, "failure-screen.png"), Buffer.from(png, "base64"))), () => null);
|
|
1336
1432
|
await Promise.all([source, screen]);
|
|
1337
1433
|
}
|
|
1338
1434
|
async function runDriver(argv, module, deps = realRunDeps()) {
|
|
@@ -1355,10 +1451,13 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1355
1451
|
throw new Error("--action-gc must be on or off");
|
|
1356
1452
|
if (a["end-gc"] !== "on" && a["end-gc"] !== "off")
|
|
1357
1453
|
throw new Error("--end-gc must be on or off");
|
|
1454
|
+
if (a.capture !== "on" && a.capture !== "off")
|
|
1455
|
+
throw new Error("--capture must be on or off");
|
|
1456
|
+
const capture = a.capture === "on";
|
|
1358
1457
|
const out = String(a.out);
|
|
1359
1458
|
const auth = basicAuth(user, key);
|
|
1360
1459
|
mkdirSync(out, { recursive: true });
|
|
1361
|
-
const { api, tokens } = parseTokensJs(
|
|
1460
|
+
const { api, tokens } = parseTokensJs(readFileSync2(String(a["tokens-js"]), "utf8"));
|
|
1362
1461
|
const log = [];
|
|
1363
1462
|
const driver = new Driver({ hub: HUB, log, headers: { Authorization: auth }, bundleId: module.app.bundleId, platform, fetch: deps.fetch, now: deps.now, sleep: deps.sleep });
|
|
1364
1463
|
const t = new Session({ driver, scheme: module.app.scheme, fetch: deps.fetch, api, actionGc: a["action-gc"] === "on", endGc: a["end-gc"] === "on" });
|
|
@@ -1378,13 +1477,15 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1378
1477
|
let flushOff = [];
|
|
1379
1478
|
const iterations = [];
|
|
1380
1479
|
let terminating = false;
|
|
1381
|
-
const logFile =
|
|
1480
|
+
const logFile = join2(out, "driver-log.json");
|
|
1382
1481
|
const persist = () => {
|
|
1383
1482
|
const last = t.stepLog.at(-1);
|
|
1384
1483
|
const body = {
|
|
1385
1484
|
driver: "appium",
|
|
1386
1485
|
status,
|
|
1387
1486
|
device,
|
|
1487
|
+
unit: driver.deviceUnit ?? null,
|
|
1488
|
+
capture,
|
|
1388
1489
|
appUrl,
|
|
1389
1490
|
session: driver.sessionId,
|
|
1390
1491
|
buildId,
|
|
@@ -1419,7 +1520,7 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1419
1520
|
}
|
|
1420
1521
|
};
|
|
1421
1522
|
let dashboard = null;
|
|
1422
|
-
const writeBuild = (failure) => writeAtomic(
|
|
1523
|
+
const writeBuild = (failure) => writeAtomic(join2(out, `browserstack.${String(a.platform)}.json`), `${JSON.stringify({
|
|
1423
1524
|
driver: "appium",
|
|
1424
1525
|
status: failure === null ? "passed" : "failed",
|
|
1425
1526
|
buildId,
|
|
@@ -1489,13 +1590,14 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1489
1590
|
try {
|
|
1490
1591
|
appUrl = await resolveApp(String(a.app), String(a["app-custom-id"] ?? ""), auth, deps.fetch, platform);
|
|
1491
1592
|
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"] });
|
|
1593
|
+
const caps = sessionCaps({ appUrl, device, buildName: String(a["build-name"]), sessionName, appiumVersion: String(a["appium-version"]), platform, localIdentifier: a["local-identifier"], capture });
|
|
1493
1594
|
startedAt = deps.now();
|
|
1494
1595
|
try {
|
|
1495
1596
|
await driver.start(caps);
|
|
1496
1597
|
deps.print(`session ${driver.sessionId}`);
|
|
1497
1598
|
progress();
|
|
1498
1599
|
await driver.cmd("POST", "/appium/settings", { settings: platform.settings }).catch(() => null);
|
|
1600
|
+
await driver.safeBottomY().catch(() => null);
|
|
1499
1601
|
await module.setup?.(t, ctx);
|
|
1500
1602
|
await runIterations(t, module, ctx, iterations, progress);
|
|
1501
1603
|
} catch (e) {
|
|
@@ -1527,8 +1629,13 @@ async function runDriver(argv, module, deps = realRunDeps()) {
|
|
|
1527
1629
|
flushOff = flushedAs(FLUSH_OFF, true);
|
|
1528
1630
|
if (flushNotAcknowledged.length)
|
|
1529
1631
|
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
|
-
|
|
1632
|
+
if (flushError.length) {
|
|
1633
|
+
const losses = [
|
|
1634
|
+
...iterations.filter((r) => r.flushed === FLUSH_ERROR).map((r) => `${r.journey} #${r.iteration} (${flushLoss(r.flush?.detail)})`),
|
|
1635
|
+
...finalFlush?.flushed === FLUSH_ERROR ? [`final flush (${flushLoss(finalFlush.flush?.detail)})`] : []
|
|
1636
|
+
];
|
|
1637
|
+
deps.error(`::error::flush answered error, data lost: ${losses.join(", ")}`);
|
|
1638
|
+
}
|
|
1532
1639
|
if (flushOff.length)
|
|
1533
1640
|
deps.error(`::error::flush answered off in a run with a token (upload not configured, nothing uploaded): ${flushOff.join(", ")}`);
|
|
1534
1641
|
endedAt = deps.now();
|
|
@@ -1587,7 +1694,7 @@ async function runIterations(t, module, ctx, iterations, progress) {
|
|
|
1587
1694
|
|
|
1588
1695
|
// packages/appium-driver/src/load.ts
|
|
1589
1696
|
async function loadJourneyModule(path, cwd = process.cwd()) {
|
|
1590
|
-
const file = isAbsolute(path) ? path :
|
|
1697
|
+
const file = isAbsolute(path) ? path : resolve2(cwd, path);
|
|
1591
1698
|
const mod = await import(pathToFileURL(file).href);
|
|
1592
1699
|
const m = mod.default ?? mod;
|
|
1593
1700
|
if (!m.app?.bundleId || !m.app.scheme)
|