@keepsnappy/agent 0.1.0-alpha.15 → 0.1.0-alpha.16
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 +33 -4
- package/dist/appium-driver.js +54 -12
- package/dist/keepsnappy.js +1278 -249
- package/package.json +1 -1
package/dist/keepsnappy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// apps/agent/src/cli.ts
|
|
4
|
-
import { resolve as
|
|
4
|
+
import { resolve as resolve8 } from "node:path";
|
|
5
5
|
import { parseArgs as parseArgs4 } from "node:util";
|
|
6
6
|
|
|
7
7
|
// apps/agent/src/config.ts
|
|
@@ -1664,6 +1664,36 @@ var MEASURES = {
|
|
|
1664
1664
|
decimals: 2,
|
|
1665
1665
|
what: "The share of samples at heat level serious or above"
|
|
1666
1666
|
},
|
|
1667
|
+
"sampler.thermal.raw_start": {
|
|
1668
|
+
unit: "state",
|
|
1669
|
+
worse: "none",
|
|
1670
|
+
at: ["journey"],
|
|
1671
|
+
producer: "derive",
|
|
1672
|
+
label: "Raw Android thermal status at the start (0–6)",
|
|
1673
|
+
short: "raw Android thermal status at the start (0–6)",
|
|
1674
|
+
decimals: 0,
|
|
1675
|
+
what: "The first raw Android thermal status reported during the journey"
|
|
1676
|
+
},
|
|
1677
|
+
"sampler.thermal.raw_max": {
|
|
1678
|
+
unit: "state",
|
|
1679
|
+
worse: "none",
|
|
1680
|
+
at: ["journey"],
|
|
1681
|
+
producer: "derive",
|
|
1682
|
+
label: "Raw Android thermal status (peak, 0–6)",
|
|
1683
|
+
short: "raw Android thermal status (peak, 0–6)",
|
|
1684
|
+
decimals: 0,
|
|
1685
|
+
what: "The highest raw Android thermal status reported during the journey"
|
|
1686
|
+
},
|
|
1687
|
+
"sampler.thermal.raw_light_share": {
|
|
1688
|
+
unit: "ratio",
|
|
1689
|
+
worse: "none",
|
|
1690
|
+
at: ["journey"],
|
|
1691
|
+
producer: "derive",
|
|
1692
|
+
label: "Share of Android samples at LIGHT or above",
|
|
1693
|
+
short: "share of Android samples at LIGHT or above",
|
|
1694
|
+
decimals: 2,
|
|
1695
|
+
what: "The share of samples with a raw Android thermal status of 1 (LIGHT) or above, among samples reporting that status"
|
|
1696
|
+
},
|
|
1667
1697
|
"sampler.thermal.heats_up": {
|
|
1668
1698
|
unit: "share",
|
|
1669
1699
|
worse: "higher",
|
|
@@ -2103,6 +2133,7 @@ var DIMENSIONS = {
|
|
|
2103
2133
|
};
|
|
2104
2134
|
var BREAKDOWN_MEASURES = {
|
|
2105
2135
|
self_ms: { unit: "ms", worse: "higher", label: "render time", decimals: 1 },
|
|
2136
|
+
rolled_up_ms: { unit: "ms", worse: "none", label: "render time from libraries", decimals: 1 },
|
|
2106
2137
|
total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
|
|
2107
2138
|
renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
|
|
2108
2139
|
mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
|
|
@@ -2112,7 +2143,53 @@ var BREAKDOWN_MEASURES = {
|
|
|
2112
2143
|
var measureDef = (id) => isMeasureId(id) ? MEASURES[id] : undefined;
|
|
2113
2144
|
var HEAT_METRIC = "sampler.thermal.heats_up";
|
|
2114
2145
|
var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
|
|
2146
|
+
var FULL_ROW_MS = 2.5;
|
|
2147
|
+
// packages/facts/dist/src/components.js
|
|
2148
|
+
var FRAGMENT_VERSION = 2;
|
|
2149
|
+
var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2150
|
+
var portable = (value) => typeof value === "string" && value.length > 0 && !/^(?:\/|[A-Za-z]:)/.test(value) && !value.includes("\\");
|
|
2151
|
+
var componentFile = (file) => portable(file) && !file.split("/").some((part) => part === ".." || part === "." || part === "");
|
|
2152
|
+
var isComponentName = (name) => typeof name === "string" && /^[\p{Lu}\p{Lt}][\p{ID_Continue}$\u200C\u200D]*$/u.test(name);
|
|
2153
|
+
var encodePart = (part) => encodeURIComponent(part).replace(/%2F/g, "/").replace(/%40/g, "@").replace(/~/g, "%7E");
|
|
2154
|
+
function decodePart(part) {
|
|
2155
|
+
try {
|
|
2156
|
+
const decoded = decodeURIComponent(part);
|
|
2157
|
+
return encodePart(decoded) === part ? decoded : null;
|
|
2158
|
+
} catch {
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function componentKey(name, file) {
|
|
2163
|
+
if (name !== "default" && !isComponentName(name) || !componentFile(file))
|
|
2164
|
+
throw new Error("invalid client component name or file");
|
|
2165
|
+
return `${name}@${encodePart(file)}`;
|
|
2166
|
+
}
|
|
2167
|
+
function parseComponentKey(key) {
|
|
2168
|
+
if (typeof key !== "string")
|
|
2169
|
+
return null;
|
|
2170
|
+
const separator = key.indexOf("@");
|
|
2171
|
+
const name = key.slice(0, separator);
|
|
2172
|
+
const file = decodePart(key.slice(separator + 1));
|
|
2173
|
+
if (separator < 1 || name !== "default" && !isComponentName(name) || !componentFile(file))
|
|
2174
|
+
return null;
|
|
2175
|
+
return componentKey(name, file) === key ? { name, file } : null;
|
|
2176
|
+
}
|
|
2177
|
+
function isComponentFragment(value) {
|
|
2178
|
+
if (!object(value) || value.version !== FRAGMENT_VERSION || !portable(value.source) || typeof value.hash !== "string" || !/^[a-f0-9]{64}$/.test(value.hash) || !Array.isArray(value.components))
|
|
2179
|
+
return false;
|
|
2180
|
+
return value.components.every((component) => {
|
|
2181
|
+
if (!object(component) || typeof component.key !== "string")
|
|
2182
|
+
return false;
|
|
2183
|
+
const parsed = parseComponentKey(component.key);
|
|
2184
|
+
return parsed !== null && parsed.name === component.name && parsed.file === component.file && Number.isInteger(component.line) && component.line > 0;
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
function isComponentConfiguration(value) {
|
|
2188
|
+
return object(value) && value.version === FRAGMENT_VERSION && typeof value.directory === "string" && /^[a-zA-Z0-9-]+$/.test(value.directory);
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2115
2191
|
// packages/facts/dist/src/places.js
|
|
2192
|
+
var placeWithoutVisit = (id) => placeRef(id).stem.replace(/#(\d+|repeat)$/, "");
|
|
2116
2193
|
var AFTER_3S = "+3s";
|
|
2117
2194
|
var kindOf = (stem) => {
|
|
2118
2195
|
if (stem === "*")
|
|
@@ -2136,16 +2213,97 @@ function placeRef(id) {
|
|
|
2136
2213
|
const stem = after3s ? id.slice(0, -AFTER_3S.length) : id;
|
|
2137
2214
|
return { id, kind: kindOf(stem), stem, segment: after3s ? "after3s" : "whole" };
|
|
2138
2215
|
}
|
|
2216
|
+
var placeSegment = (name, file) => `${name}@${encodePart(file)}`;
|
|
2217
|
+
var buildPlaceId = (parts) => {
|
|
2218
|
+
if (parts.kind === "screen")
|
|
2219
|
+
return `screen:${parts.screen}#${parts.visit}`;
|
|
2220
|
+
if (parts.kind === "launch")
|
|
2221
|
+
return `launch:${parts.screen}`;
|
|
2222
|
+
return `${parts.kind}:${parts.screen}›${parts.parent ? parts.parent + "›" : ""}${parts.target}${parts.k ? "~" + parts.k : ""}`;
|
|
2223
|
+
};
|
|
2224
|
+
function parseClientKey(key) {
|
|
2225
|
+
const anonymous = key.startsWith("default@");
|
|
2226
|
+
const parsed = parseComponentKey(anonymous ? "Default" + key.slice(7) : key);
|
|
2227
|
+
if (!parsed)
|
|
2228
|
+
return null;
|
|
2229
|
+
const name = anonymous ? "default" : parsed.name;
|
|
2230
|
+
return placeSegment(name, parsed.file) === key ? { name, file: parsed.file } : null;
|
|
2231
|
+
}
|
|
2232
|
+
var decodePlaceSegment = (key) => {
|
|
2233
|
+
if (key === "(no screen)")
|
|
2234
|
+
return { key, name: key, file: null };
|
|
2235
|
+
const at = key.indexOf("@");
|
|
2236
|
+
if (at < 1 || at === key.length - 1)
|
|
2237
|
+
return null;
|
|
2238
|
+
try {
|
|
2239
|
+
const name = key.slice(0, at);
|
|
2240
|
+
const file = decodePart(key.slice(at + 1));
|
|
2241
|
+
if (file === null)
|
|
2242
|
+
return null;
|
|
2243
|
+
if (name !== "(outside app components)" && !parseClientKey(key))
|
|
2244
|
+
return null;
|
|
2245
|
+
return placeSegment(name, file) === key ? { key, name, file } : null;
|
|
2246
|
+
} catch {
|
|
2247
|
+
return null;
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
var placeParts = (id) => {
|
|
2251
|
+
const stem = id.replace(/\+3s$/, "");
|
|
2252
|
+
const suffix = /#(\d+|repeat)$/.exec(stem);
|
|
2253
|
+
const base = suffix ? stem.slice(0, suffix.index) : stem;
|
|
2254
|
+
const occurrence = /~(\d+)$/.exec(base);
|
|
2255
|
+
const body = occurrence ? base.slice(0, occurrence.index) : base;
|
|
2256
|
+
const colon = body.indexOf(":");
|
|
2257
|
+
const kind = body.slice(0, colon);
|
|
2258
|
+
if (kind !== "screen" && kind !== "launch" && kind !== "press" && kind !== "drag")
|
|
2259
|
+
return null;
|
|
2260
|
+
const segments = body.slice(colon + 1).split("›");
|
|
2261
|
+
if (kind === "screen" || kind === "launch" ? segments.length !== 1 : segments.length < 2 || segments.length > 3)
|
|
2262
|
+
return null;
|
|
2263
|
+
const parts = segments.map(decodePlaceSegment);
|
|
2264
|
+
if (parts.some((p) => p === null))
|
|
2265
|
+
return null;
|
|
2266
|
+
if (parts.slice(1).some((p) => p.name === "(no screen)"))
|
|
2267
|
+
return null;
|
|
2268
|
+
if (parts.length === 3 && parts[1].name === "(outside app components)")
|
|
2269
|
+
return null;
|
|
2270
|
+
const screen = parts[0];
|
|
2271
|
+
const target = kind === "press" || kind === "drag" ? parts.at(-1) : null;
|
|
2272
|
+
return {
|
|
2273
|
+
kind,
|
|
2274
|
+
screen,
|
|
2275
|
+
target,
|
|
2276
|
+
parent: parts.length === 3 ? parts[1] : null,
|
|
2277
|
+
k: occurrence ? Number(occurrence[1]) : null,
|
|
2278
|
+
visit: suffix ? suffix[1] === "repeat" ? "repeat" : Number(suffix[1]) : null,
|
|
2279
|
+
outsideHost: target?.name === "(outside app components)" ? target.file : null
|
|
2280
|
+
};
|
|
2281
|
+
};
|
|
2139
2282
|
// packages/facts/dist/src/frame.js
|
|
2140
2283
|
var UNKNOWN_REFRESH_HZ = 60;
|
|
2141
2284
|
var LIMIT_FRAME_MS = 1000 / 60;
|
|
2142
2285
|
function frameBudgetMs(refreshHz) {
|
|
2143
2286
|
return 1000 / (refreshHz !== null && refreshHz !== undefined && Number.isFinite(refreshHz) && refreshHz > 0 ? refreshHz : UNKNOWN_REFRESH_HZ);
|
|
2144
2287
|
}
|
|
2288
|
+
// packages/facts/dist/src/profile-items.js
|
|
2289
|
+
function encodeProfileItems(rows, fullRowMs = FULL_ROW_MS) {
|
|
2290
|
+
const components = [], small = [];
|
|
2291
|
+
for (const row of rows) {
|
|
2292
|
+
if (row.selfMs === null || row.selfMs >= fullRowMs)
|
|
2293
|
+
components.push(row);
|
|
2294
|
+
else
|
|
2295
|
+
small.push([row.key ?? row.name, row.selfMs]);
|
|
2296
|
+
}
|
|
2297
|
+
small.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
2298
|
+
return { components, ...small.length ? { small } : {} };
|
|
2299
|
+
}
|
|
2145
2300
|
// packages/derive/dist/src/launch.js
|
|
2146
2301
|
var obj = (v) => typeof v === "object" && v !== null ? v : {};
|
|
2147
2302
|
var isScreenWindow = (w) => w.raw.kind === "screen" || w.name.startsWith("screen:");
|
|
2148
|
-
var launchPlaceOf = (name) =>
|
|
2303
|
+
var launchPlaceOf = (name) => {
|
|
2304
|
+
const p = placeParts(name);
|
|
2305
|
+
return p ? buildPlaceId({ kind: "launch", screen: p.screen.key }) : `launch:${placeWithoutVisit(name).replace(/^screen:/, "")}`;
|
|
2306
|
+
};
|
|
2149
2307
|
var isScreenName = (name) => name.startsWith("screen:") || name.startsWith("launch:");
|
|
2150
2308
|
function withLaunchNames(windows) {
|
|
2151
2309
|
const screens = windows.filter(isScreenWindow);
|
|
@@ -2154,6 +2312,9 @@ function withLaunchNames(windows) {
|
|
|
2154
2312
|
const isLaunch = (w) => marked ? obj(w.raw.screen).launch === true : w === first;
|
|
2155
2313
|
return windows.map((w) => isScreenWindow(w) && isLaunch(w) ? { ...w, name: launchPlaceOf(w.name) } : w);
|
|
2156
2314
|
}
|
|
2315
|
+
|
|
2316
|
+
// packages/derive/dist/src/model.js
|
|
2317
|
+
var DERIVATION_VERSION = 27;
|
|
2157
2318
|
// packages/stats/dist/src/core/rng.js
|
|
2158
2319
|
function splitmix32(seed) {
|
|
2159
2320
|
let s = seed >>> 0;
|
|
@@ -3130,7 +3291,7 @@ var obj3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v :
|
|
|
3130
3291
|
var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
3131
3292
|
var round = (x, digits = 3) => Math.round(x * 10 ** digits) / 10 ** digits;
|
|
3132
3293
|
var HOST_COMPONENTS = new Set(["View", "Text", "Image", "TextInput", "ScrollView"]);
|
|
3133
|
-
var isHostComponent = (name) => HOST_COMPONENTS.has(name) || /^RCT[A-Z]/.test(name);
|
|
3294
|
+
var isHostComponent = (name) => !parseComponentKey(name) && (HOST_COMPONENTS.has(name) || /^RCT[A-Z]/.test(name));
|
|
3134
3295
|
function causeCounts(raw, mounts) {
|
|
3135
3296
|
const c = obj3(raw);
|
|
3136
3297
|
const out = {};
|
|
@@ -3150,7 +3311,7 @@ function causeCounts(raw, mounts) {
|
|
|
3150
3311
|
}
|
|
3151
3312
|
return out;
|
|
3152
3313
|
}
|
|
3153
|
-
function componentRow(row, causes) {
|
|
3314
|
+
function componentRow(row, causes, rollup, detail) {
|
|
3154
3315
|
if (!Array.isArray(row) || typeof row[0] !== "string")
|
|
3155
3316
|
return;
|
|
3156
3317
|
const six = row.length >= 6;
|
|
@@ -3158,15 +3319,57 @@ function componentRow(row, causes) {
|
|
|
3158
3319
|
const mounts = num2(row[2]) ?? 0;
|
|
3159
3320
|
return {
|
|
3160
3321
|
name: row[0],
|
|
3322
|
+
...detail !== false && Object.hasOwn(rollup, row[0]) ? rollupOf(rollup[row[0]]) : {},
|
|
3161
3323
|
renders,
|
|
3162
3324
|
mounts,
|
|
3163
3325
|
instances: six ? num2(row[3]) ?? null : null,
|
|
3164
3326
|
selfMs: num2(row[six ? 4 : 3]) ?? null,
|
|
3165
3327
|
totalMs: num2(row[six ? 5 : 4]) ?? null,
|
|
3166
|
-
causes: causeCounts(causes[row[0]], mounts)
|
|
3328
|
+
causes: detail === false ? {} : causeCounts(causes[row[0]], mounts),
|
|
3329
|
+
...detail === undefined ? {} : { detailRenders: detail ? renders : 0 }
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
function rollupOf(raw) {
|
|
3333
|
+
const x = obj3(raw), rest = obj3(x.rolledUpRest);
|
|
3334
|
+
const rolledUp = (Array.isArray(x.rolledUp) ? x.rolledUp : []).flatMap((row) => Array.isArray(row) && typeof row[0] === "string" && num2(row[1]) !== undefined && num2(row[2]) !== undefined ? [{ name: row[0], ms: row[1], renders: row[2] }] : []);
|
|
3335
|
+
return {
|
|
3336
|
+
rolledUp,
|
|
3337
|
+
rolledUpRest: { ms: num2(rest.ms) ?? 0, n: num2(rest.n) ?? 0, ...rest.saturated === true ? { saturated: true } : {} },
|
|
3338
|
+
...typeof x.parent === "string" ? { parent: x.parent, parentOccurrences: { [x.parent]: 1 } } : {},
|
|
3339
|
+
...num2(x.rolledUpMs) === undefined ? {} : { rolledUpMs: num2(x.rolledUpMs) }
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
function mergeRollups(a, b) {
|
|
3343
|
+
const names = new Map;
|
|
3344
|
+
for (const row of [...a.rolledUp ?? [], ...b.rolledUp ?? []]) {
|
|
3345
|
+
const was = names.get(row.name);
|
|
3346
|
+
names.set(row.name, { name: row.name, ms: (was?.ms ?? 0) + row.ms, renders: (was?.renders ?? 0) + row.renders });
|
|
3347
|
+
}
|
|
3348
|
+
const x = a.rolledUpRest, y = b.rolledUpRest;
|
|
3349
|
+
return { rolledUp: [...names.values()], rolledUpRest: {
|
|
3350
|
+
ms: (x?.ms ?? 0) + (y?.ms ?? 0),
|
|
3351
|
+
n: Math.max(x?.n ?? 0, y?.n ?? 0),
|
|
3352
|
+
...x?.saturated || y?.saturated ? { saturated: true } : {},
|
|
3353
|
+
...x?.nAtLeast || y?.nAtLeast || x?.n && y?.n ? { nAtLeast: true } : {}
|
|
3354
|
+
} };
|
|
3355
|
+
}
|
|
3356
|
+
function shownRollup(r, n) {
|
|
3357
|
+
const ranked = [...r.rolledUp ?? []].sort((a, b) => b.ms - a.ms || a.name.localeCompare(b.name));
|
|
3358
|
+
const cut = ranked.slice(5), rest = r.rolledUpRest;
|
|
3359
|
+
return {
|
|
3360
|
+
rolledUp: ranked.slice(0, 5).map((x) => ({ name: x.name, ms: round(x.ms / n), renders: round(x.renders / n) })),
|
|
3361
|
+
rolledUpRest: { ms: round(((rest?.ms ?? 0) + cut.reduce((s, x) => s + x.ms, 0)) / n), n: Math.max(rest?.n ?? 0, cut.length), ...rest?.saturated ? { saturated: true } : {}, ...rest?.nAtLeast || rest?.n && cut.length ? { nAtLeast: true } : {} }
|
|
3167
3362
|
};
|
|
3168
3363
|
}
|
|
3169
|
-
function
|
|
3364
|
+
function outsideOf(profiles, n) {
|
|
3365
|
+
const parts = profiles.flatMap((p) => p.outside ? [p.outside] : []);
|
|
3366
|
+
if (!parts.length)
|
|
3367
|
+
return {};
|
|
3368
|
+
const merged = parts.reduce((a, b) => mergeRollups(a, b), {});
|
|
3369
|
+
return { outside: { ...n === undefined ? merged : shownRollup(merged, n), selfMs: round(parts.reduce((s, p) => s + p.selfMs, 0) / (n ?? 1)), mountSelfMs: round(parts.reduce((s, p) => s + p.mountSelfMs, 0) / (n ?? 1)) } };
|
|
3370
|
+
}
|
|
3371
|
+
var stampsOf = (ps) => ps.every((p) => p.stamped !== undefined) ? { stamped: ps.reduce((s, p) => s + p.stamped, 0) } : {};
|
|
3372
|
+
function windowProfile(raw, detailCoverage = true) {
|
|
3170
3373
|
const p = obj3(obj3(raw.react).profile);
|
|
3171
3374
|
if (p.renderer !== "profiling" && p.renderer !== "production")
|
|
3172
3375
|
return;
|
|
@@ -3187,11 +3390,14 @@ function windowProfile(raw) {
|
|
|
3187
3390
|
}
|
|
3188
3391
|
return {
|
|
3189
3392
|
renderer: p.renderer,
|
|
3393
|
+
...Number.isSafeInteger(p.slowestCommitSeq) && p.slowestCommitSeq >= 0 && num2(r.max) !== undefined && r.max >= 0 ? { slowestCommit: { seq: p.slowestCommitSeq, endAtMs: num2(p.slowestCommitEndAtMs) ?? null, ms: r.max } } : {},
|
|
3190
3394
|
commits: num2(p.commits) ?? 0,
|
|
3395
|
+
...num2(p.stamped) === undefined ? {} : { stamped: num2(p.stamped) },
|
|
3396
|
+
...p.outside && p.renderer === "profiling" ? { outside: { ...rollupOf(p.outside), selfMs: num2(obj3(p.outside).selfMs) ?? 0, mountSelfMs: num2(obj3(p.outside).mountSelfMs) ?? 0 } } : {},
|
|
3191
3397
|
renderMs: p.renderer === "profiling" && sum !== undefined ? { sum, max: num2(r.max) ?? sum, n: num2(r.n) ?? 0 } : null,
|
|
3192
3398
|
overBudget: Object.keys(overBudget).length ? overBudget : null,
|
|
3193
3399
|
updaters,
|
|
3194
|
-
components: (Array.isArray(p.components) ? p.components : []).map((row) => componentRow(row, causes)).filter((c) => !!c),
|
|
3400
|
+
components: (Array.isArray(p.components) ? p.components : []).map((row, index) => componentRow(row, causes, obj3(p.rollup), detailCoverage ? index < (num2(p.detailRows) ?? Infinity) : undefined)).filter((c) => !!c),
|
|
3195
3401
|
omitted: num2(p.omitted) ?? 0,
|
|
3196
3402
|
selfMsTotal: num2(p.selfMsTotal) ?? null,
|
|
3197
3403
|
mountSelfMs: num2(p.mountSelfMs) ?? null
|
|
@@ -3242,13 +3448,23 @@ function addComponent(into, c) {
|
|
|
3242
3448
|
into.set(c.name, {
|
|
3243
3449
|
name: c.name,
|
|
3244
3450
|
renders: was.renders + c.renders,
|
|
3451
|
+
...was.detailRenders === undefined && c.detailRenders === undefined ? {} : { detailRenders: (was.detailRenders ?? was.renders) + (c.detailRenders ?? c.renders) },
|
|
3245
3452
|
mounts: was.mounts + c.mounts,
|
|
3246
3453
|
instances: was.instances === null && c.instances === null ? null : Math.max(was.instances ?? 0, c.instances ?? 0),
|
|
3247
3454
|
selfMs: plus(was.selfMs, c.selfMs),
|
|
3248
3455
|
totalMs: plus(was.totalMs, c.totalMs),
|
|
3249
|
-
causes
|
|
3456
|
+
causes,
|
|
3457
|
+
...was.rolledUpMs === undefined && c.rolledUpMs === undefined ? {} : { rolledUpMs: (was.rolledUpMs ?? 0) + (c.rolledUpMs ?? 0), ...mergeRollups(was, c) },
|
|
3458
|
+
...mergedParent(was, c)
|
|
3250
3459
|
});
|
|
3251
3460
|
}
|
|
3461
|
+
function mergedParent(a, b) {
|
|
3462
|
+
const counts = { ...a.parentOccurrences };
|
|
3463
|
+
for (const [k, v] of Object.entries(b.parentOccurrences ?? {}))
|
|
3464
|
+
counts[k] = (counts[k] ?? 0) + v;
|
|
3465
|
+
const parent = Object.keys(counts).sort((a, b) => counts[b] - counts[a] || a.localeCompare(b))[0];
|
|
3466
|
+
return parent ? { parent, parentOccurrences: counts } : {};
|
|
3467
|
+
}
|
|
3252
3468
|
function exactMountOf(profiles) {
|
|
3253
3469
|
let selfMsTotal = 0;
|
|
3254
3470
|
let mountSelfMs = 0;
|
|
@@ -3262,17 +3478,20 @@ function exactMountOf(profiles) {
|
|
|
3262
3478
|
}
|
|
3263
3479
|
return { selfMsTotal, mountSelfMs };
|
|
3264
3480
|
}
|
|
3265
|
-
function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = false) {
|
|
3481
|
+
function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = false, allComponents = false) {
|
|
3266
3482
|
const picked = [];
|
|
3267
3483
|
for (const cl of clusters(windows)) {
|
|
3268
3484
|
const withProfile = cl.flatMap((w) => {
|
|
3269
|
-
const p = windowProfile(w.raw);
|
|
3485
|
+
const p = windowProfile(w.raw, allComponents);
|
|
3270
3486
|
return p ? [{ w, p }] : [];
|
|
3271
3487
|
});
|
|
3272
3488
|
if (!withProfile.length)
|
|
3273
3489
|
continue;
|
|
3274
3490
|
const weight = (x) => x.p.renderMs?.sum ?? x.p.components.reduce((s, c) => s + c.renders, 0);
|
|
3275
|
-
picked.push(withProfile.reduce((best, x) =>
|
|
3491
|
+
picked.push(withProfile.reduce((best, x) => {
|
|
3492
|
+
const span = (y) => y.w.endWall - y.w.startWall;
|
|
3493
|
+
return weight(x) > weight(best) || weight(x) === weight(best) && (span(x) > span(best) || allComponents && span(x) === span(best) && windowOrder(x.w, best.w) < 0) ? x : best;
|
|
3494
|
+
}));
|
|
3276
3495
|
}
|
|
3277
3496
|
if (!picked.length)
|
|
3278
3497
|
return;
|
|
@@ -3295,10 +3514,13 @@ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = fal
|
|
|
3295
3514
|
overFrame = f === undefined || overFrame === undefined ? undefined : overFrame + f;
|
|
3296
3515
|
}
|
|
3297
3516
|
}
|
|
3517
|
+
const chosen = [...picked].sort((a, b) => (b.p.renderMs?.max ?? -1) - (a.p.renderMs?.max ?? -1) || windowOrder(a.w, b.w))[0];
|
|
3298
3518
|
const renderMs = timed ? picked.reduce((s, x) => s + (x.p.renderMs?.sum ?? 0), 0) : allIdle ? 0 : undefined;
|
|
3299
3519
|
const parts = picked.map((x) => timerCarriedRenderMs(x.w.raw));
|
|
3300
3520
|
const carried = parts.every((v) => v !== undefined) ? parts.reduce((s, v) => s + v, 0) : undefined;
|
|
3301
3521
|
return {
|
|
3522
|
+
window: chosen.w,
|
|
3523
|
+
...chosen.p.slowestCommit ? { slowestCommit: chosen.p.slowestCommit } : {},
|
|
3302
3524
|
renderer: timed ? "profiling" : "production",
|
|
3303
3525
|
commits: picked.reduce((s, x) => s + x.p.commits, 0),
|
|
3304
3526
|
renderMs,
|
|
@@ -3308,20 +3530,24 @@ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = fal
|
|
|
3308
3530
|
updaters,
|
|
3309
3531
|
components,
|
|
3310
3532
|
omitted: picked.reduce((s, x) => s + x.p.omitted, 0),
|
|
3311
|
-
distinctInWindow: Math.max(...picked.map((x) => x.p.components.length + x.p.omitted)),
|
|
3533
|
+
distinctInWindow: Math.max(...picked.map((x) => (allComponents ? x.p.components.filter((c) => !isHostComponent(c.name)).length : x.p.components.length) + x.p.omitted)),
|
|
3312
3534
|
exactMount: exactMountOf(picked.map((x) => x.p)),
|
|
3535
|
+
...outsideOf(picked.map((x) => x.p)),
|
|
3536
|
+
...stampsOf(picked.map((x) => x.p)),
|
|
3313
3537
|
...allIdle ? { idle: true } : {},
|
|
3314
3538
|
...upper ? { timerCarriedMs: carried, renderMsUpper: renderMs === undefined || carried === undefined ? undefined : renderMs + carried } : {}
|
|
3315
3539
|
};
|
|
3316
3540
|
}
|
|
3317
3541
|
var UPDATERS_KEPT = 5;
|
|
3542
|
+
var CPU_FUNCTIONS_KEPT = 10;
|
|
3543
|
+
var UNTIMED_COMPONENTS_KEPT = 10;
|
|
3318
3544
|
var topCauses = (causes, renders) => {
|
|
3319
3545
|
if (renders <= 0)
|
|
3320
3546
|
return {};
|
|
3321
3547
|
const entries = Object.entries(causes).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3);
|
|
3322
3548
|
return Object.fromEntries(entries.map(([k, v]) => [k, round(v / renders)]));
|
|
3323
3549
|
};
|
|
3324
|
-
function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
|
|
3550
|
+
function stepProfile(step, occ, budgetMs, topN, cpu, exactMount, fullRowMs) {
|
|
3325
3551
|
const n = occ.length;
|
|
3326
3552
|
const mean = (x) => x / n;
|
|
3327
3553
|
const timed = occ.every((o) => o.renderer === "profiling");
|
|
@@ -3334,8 +3560,9 @@ function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
|
|
|
3334
3560
|
const host = [...all.values()].filter((c) => isHostComponent(c.name));
|
|
3335
3561
|
const rank = (a, b) => (timed ? (b.selfMs ?? 0) - (a.selfMs ?? 0) : 0) || b.renders - a.renders || a.name.localeCompare(b.name);
|
|
3336
3562
|
own.sort(rank);
|
|
3337
|
-
const
|
|
3338
|
-
const
|
|
3563
|
+
const limit = fullRowMs === undefined ? topN : timed ? own.length : UNTIMED_COMPONENTS_KEPT;
|
|
3564
|
+
const kept = own.slice(0, limit);
|
|
3565
|
+
const rest = own.slice(limit);
|
|
3339
3566
|
const ms = (x) => timed && x !== null ? round(mean(x)) : null;
|
|
3340
3567
|
const updaters = {};
|
|
3341
3568
|
for (const o of occ)
|
|
@@ -3361,31 +3588,36 @@ function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
|
|
|
3361
3588
|
cpuOut = {
|
|
3362
3589
|
busyMs: round(median(cpu.map((c) => c.busyMs))),
|
|
3363
3590
|
symbolicated: sym.length ? sym.every(Boolean) : null,
|
|
3364
|
-
functions: [...fns.values()].sort((a, b) => b.self - a.self || a.key.localeCompare(b.key)).slice(0, topN).map((f) => ({ key: f.key, name: f.name, selfMs: round(f.self / k), totalMs: f.total === null ? null : round(f.total / k) }))
|
|
3591
|
+
functions: [...fns.values()].sort((a, b) => b.self - a.self || a.key.localeCompare(b.key)).slice(0, fullRowMs === undefined ? topN : CPU_FUNCTIONS_KEPT).map((f) => ({ key: f.key, name: f.name, selfMs: round(f.self / k), totalMs: f.total === null ? null : round(f.total / k) }))
|
|
3365
3592
|
};
|
|
3366
3593
|
}
|
|
3594
|
+
const rows = kept.map((c) => ({
|
|
3595
|
+
key: c.name,
|
|
3596
|
+
...c.parent ? { parent: c.parent } : {},
|
|
3597
|
+
...c.rolledUpMs === undefined ? {} : { rolledUpMs: round(mean(c.rolledUpMs)), ...shownRollup(c, n) },
|
|
3598
|
+
name: c.name,
|
|
3599
|
+
renders: round(mean(c.renders)),
|
|
3600
|
+
mounts: round(mean(c.mounts)),
|
|
3601
|
+
instances: c.instances,
|
|
3602
|
+
selfMs: ms(c.selfMs),
|
|
3603
|
+
totalMs: ms(c.totalMs),
|
|
3604
|
+
causes: topCauses(c.causes, c.detailRenders ?? c.renders)
|
|
3605
|
+
}));
|
|
3367
3606
|
return {
|
|
3368
3607
|
step,
|
|
3369
3608
|
occurrences: n,
|
|
3609
|
+
...outsideOf(occ, n),
|
|
3610
|
+
...stampsOf(occ),
|
|
3370
3611
|
renderer,
|
|
3371
3612
|
frameBudgetMs: round(budgetMs, 1),
|
|
3372
3613
|
commits: round(mean(commits)),
|
|
3373
3614
|
renderMs: timed ? round(mean(occ.reduce((s, o) => s + (o.renderMs ?? 0), 0))) : null,
|
|
3374
3615
|
commitMaxMs: timed ? round(Math.max(...occ.map((o) => o.commitMaxMs ?? 0))) : null,
|
|
3375
|
-
|
|
3376
|
-
key: c.name,
|
|
3377
|
-
name: c.name,
|
|
3378
|
-
renders: round(mean(c.renders)),
|
|
3379
|
-
mounts: round(mean(c.mounts)),
|
|
3380
|
-
instances: c.instances,
|
|
3381
|
-
selfMs: ms(c.selfMs),
|
|
3382
|
-
totalMs: ms(c.totalMs),
|
|
3383
|
-
causes: topCauses(c.causes, c.renders)
|
|
3384
|
-
})),
|
|
3616
|
+
...fullRowMs !== undefined && timed ? encodeProfileItems(rows, fullRowMs) : { components: rows },
|
|
3385
3617
|
host: { renders: round(mean(host.reduce((s, c) => s + c.renders, 0))), selfMs: ms(host.reduce((s, c) => s + (c.selfMs ?? 0), 0)) },
|
|
3386
3618
|
omitted: {
|
|
3387
3619
|
count: rest.length,
|
|
3388
|
-
selfMs: ms(rest.reduce((s, c) => s + (c.selfMs ?? 0), 0)),
|
|
3620
|
+
selfMs: fullRowMs !== undefined && timed && occ.some((o) => o.omitted > 0) ? exact.every((e) => e !== undefined) ? Math.max(0, ms(exact.reduce((s, e) => s + e.selfMsTotal, 0) - [...all.values()].reduce((s, c) => s + (c.selfMs ?? 0), 0) - occ.reduce((s, o) => s + (o.outside?.selfMs ?? 0), 0))) : null : ms(rest.reduce((s, c) => s + (c.selfMs ?? 0), 0)),
|
|
3389
3621
|
sdkOmitted: occ.reduce((s, o) => s + o.omitted, 0)
|
|
3390
3622
|
},
|
|
3391
3623
|
updaters: commits > 0 ? Object.entries(updaters).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, UPDATERS_KEPT).map(([k, v]) => [k, round(v / commits)]) : [],
|
|
@@ -3423,7 +3655,7 @@ function profileMetrics(windows, steps, options = {}) {
|
|
|
3423
3655
|
for (const [name, all] of [...byName].sort((a, b) => a[0].localeCompare(b[0])))
|
|
3424
3656
|
groups.push([name, all.map((w) => [w])]);
|
|
3425
3657
|
for (const [key, occs] of groups) {
|
|
3426
|
-
const found = occs.map((ws) => occurrence(ws.filter((w) => w.raw.endReason !== "timeout"), budget, options.idleWindowsNeutral, options.countFrameMs, options.upperBound === true)).filter((o) => !!o);
|
|
3658
|
+
const found = occs.map((ws) => occurrence(ws.filter((w) => w.raw.endReason !== "timeout"), budget, options.idleWindowsNeutral, options.countFrameMs, options.upperBound === true, options.fullRowMs !== undefined)).filter((o) => !!o);
|
|
3427
3659
|
const decided = found.some((o) => !o.idle) && found.every((o) => o.idle || o.renderer === "profiling");
|
|
3428
3660
|
const occ = decided ? found.map((o) => o.idle ? { ...o, renderer: "profiling" } : o) : found;
|
|
3429
3661
|
const cpu = options.cpu?.get(key);
|
|
@@ -3438,10 +3670,21 @@ function profileMetrics(windows, steps, options = {}) {
|
|
|
3438
3670
|
if (vals.length && vals.every((v) => v !== undefined))
|
|
3439
3671
|
metrics.set(`${key}.${f}`, median(vals));
|
|
3440
3672
|
}
|
|
3441
|
-
|
|
3673
|
+
const profile = stepProfile(key, occ, budget, topN, cpu, options.exactMount === true, options.fullRowMs);
|
|
3674
|
+
const kind = placeRef(key).kind;
|
|
3675
|
+
const active = occ.filter((o) => !o.idle);
|
|
3676
|
+
const selected = middleBy((active.length ? active : occ).filter((o) => o.window), (o) => o.commitMaxMs, (o) => num2(o.window.raw.id) ?? 0, (o) => num2(o.window.raw.t0) ?? o.window.startWall);
|
|
3677
|
+
const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && ["screen", "press", "drag", "other"].includes(kind) && selected?.slowestCommit;
|
|
3678
|
+
const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
|
|
3679
|
+
profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
|
|
3442
3680
|
}
|
|
3443
3681
|
return { metrics, profiles };
|
|
3444
3682
|
}
|
|
3683
|
+
var windowOrder = (a, b) => (num2(a.raw.id) ?? 0) - (num2(b.raw.id) ?? 0) || (num2(a.raw.t0) ?? a.startWall) - (num2(b.raw.t0) ?? b.startWall);
|
|
3684
|
+
function middleBy(items, time, id, t0) {
|
|
3685
|
+
const ordered = [...items].sort((a, b) => (time(a) ?? -1) - (time(b) ?? -1) || id(a) - id(b) || t0(a) - t0(b));
|
|
3686
|
+
return ordered[Math.floor((ordered.length - 1) / 2)];
|
|
3687
|
+
}
|
|
3445
3688
|
|
|
3446
3689
|
// packages/derive/dist/src/facts.js
|
|
3447
3690
|
var ROUTE_CHANGE_ROW = "route_change";
|
|
@@ -4287,6 +4530,14 @@ function v6Metrics(windows, samples, t0, t1, version) {
|
|
|
4287
4530
|
out.set("sampler.thermal.start", thermal[0]);
|
|
4288
4531
|
out.set("sampler.thermal.hot_share", thermal.filter((v) => v >= 2).length / thermal.length);
|
|
4289
4532
|
}
|
|
4533
|
+
if (version >= 27) {
|
|
4534
|
+
const raw = inRun.map((s) => num7(s.raw.thermalRaw)).filter((v) => v !== undefined);
|
|
4535
|
+
if (raw.length) {
|
|
4536
|
+
out.set("sampler.thermal.raw_start", raw[0]);
|
|
4537
|
+
out.set("sampler.thermal.raw_max", Math.max(...raw));
|
|
4538
|
+
out.set("sampler.thermal.raw_light_share", raw.filter((v) => v >= 1).length / raw.length);
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4290
4541
|
const hz = inRun.map((s) => num7(obj7(s.raw.frames).maxHz)).filter((v) => v !== undefined);
|
|
4291
4542
|
if (hz.length)
|
|
4292
4543
|
out.set("sampler.max_hz", Math.max(...hz));
|
|
@@ -4601,12 +4852,14 @@ function endsHome(windows, endCommandWall) {
|
|
|
4601
4852
|
}
|
|
4602
4853
|
function endMetrics(markers, events, samples, windows, launchMb, opts = {}) {
|
|
4603
4854
|
const out = new Map;
|
|
4604
|
-
const
|
|
4855
|
+
const declared = markers.some((m) => m.phase === "start");
|
|
4856
|
+
const readsEnd = declared || opts.markerless === true && events.gc.length > 0;
|
|
4857
|
+
const n = readsEnd ? runEndCommand(markers, events, windows) : null;
|
|
4605
4858
|
const command = n === null ? undefined : events.gc.find((g) => g.n === n && g.status === "ok");
|
|
4606
|
-
const home = endsHome(windows, command?.wall);
|
|
4859
|
+
const home = endsHome(windows, declared ? command?.wall : undefined);
|
|
4607
4860
|
if (home !== undefined)
|
|
4608
4861
|
out.set("sdk.journey_ends_home", home);
|
|
4609
|
-
if (!
|
|
4862
|
+
if (!readsEnd)
|
|
4610
4863
|
return out;
|
|
4611
4864
|
out.set("sdk.end_gc", command ? 1 : 0);
|
|
4612
4865
|
const g = n === null ? undefined : cleanOf(events, windows).get(n);
|
|
@@ -4614,6 +4867,8 @@ function endMetrics(markers, events, samples, windows, launchMb, opts = {}) {
|
|
|
4614
4867
|
return out;
|
|
4615
4868
|
const post = g.post === undefined ? undefined : footprintsBySeq(samples).get(g.post);
|
|
4616
4869
|
const manual = opts.manualRead ? manualReadFootprint(samples, g.n) : undefined;
|
|
4870
|
+
if (!declared && manual === undefined)
|
|
4871
|
+
return out;
|
|
4617
4872
|
const footprint = manual ?? post;
|
|
4618
4873
|
if (footprint !== undefined) {
|
|
4619
4874
|
if (opts.manualRead)
|
|
@@ -4678,8 +4933,7 @@ function keyFirstOpens(windows) {
|
|
|
4678
4933
|
}
|
|
4679
4934
|
|
|
4680
4935
|
// packages/derive/dist/src/derive.js
|
|
4681
|
-
var
|
|
4682
|
-
var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23];
|
|
4936
|
+
var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27];
|
|
4683
4937
|
var windowsForVersion = (windows, version = DERIVATION_VERSION) => version >= 22 ? keyFirstOpens(windows) : windows;
|
|
4684
4938
|
function stepWindowsFor(windows, version = DERIVATION_VERSION) {
|
|
4685
4939
|
return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version);
|
|
@@ -4698,6 +4952,22 @@ function samplerFrameBudgetMs(samples) {
|
|
|
4698
4952
|
}
|
|
4699
4953
|
return hz > 1 ? frameBudgetMs(Math.round(hz)) : undefined;
|
|
4700
4954
|
}
|
|
4955
|
+
function profileOptions(windows, version, processWindows = windows) {
|
|
4956
|
+
if (version < 26)
|
|
4957
|
+
return {};
|
|
4958
|
+
const processes = new Set(processWindows.map((w) => w.raw.react?.causes?.process).filter((p) => typeof p === "string"));
|
|
4959
|
+
const presses = windows.filter((w) => w.raw.kind === "press" && placeRef(w.name).kind === "press" && placeRef(w.name).segment !== "after3s").sort((a, b) => appClock(b)[0] - appClock(a)[0] || a.name.localeCompare(b.name));
|
|
4960
|
+
const openedBy = new Map;
|
|
4961
|
+
for (const screen of windows.filter((w) => placeRef(w.name).kind === "screen")) {
|
|
4962
|
+
const start = appClock(screen)[0], opener = presses.find((w) => {
|
|
4963
|
+
const [from, to] = appClock(w);
|
|
4964
|
+
return from <= start && start < to;
|
|
4965
|
+
});
|
|
4966
|
+
if (opener)
|
|
4967
|
+
openedBy.set(screen, opener.name);
|
|
4968
|
+
}
|
|
4969
|
+
return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
|
|
4970
|
+
}
|
|
4701
4971
|
function deriveRun(input) {
|
|
4702
4972
|
const { run, markers = [], ri, clockOffsetMs, samples = [], memWindow } = input;
|
|
4703
4973
|
const version = input.derivationVersion ?? DERIVATION_VERSION;
|
|
@@ -4719,7 +4989,9 @@ function deriveRun(input) {
|
|
|
4719
4989
|
return { rows, steps, run: final, profiles: [], derivationVersion: version };
|
|
4720
4990
|
const budget = input.frameBudgetMs ?? (version >= 6 ? samplerFrameBudgetMs(samplesInRun(samples, final.t0, final.t1)) : undefined);
|
|
4721
4991
|
const compareMs = version >= 20 ? LIMIT_FRAME_MS : budget ?? frameBudgetMs();
|
|
4992
|
+
const allComponents = profileOptions(windows, version, input.windows);
|
|
4722
4993
|
const prof = profileMetrics(windows, steps, {
|
|
4994
|
+
...allComponents,
|
|
4723
4995
|
frameBudgetMs: version >= 11 ? compareMs : Number(compareMs.toFixed(1)),
|
|
4724
4996
|
...version >= 12 ? { countFrameMs: LIMIT_FRAME_MS } : {},
|
|
4725
4997
|
...cpu === undefined ? {} : { cpu },
|
|
@@ -4729,6 +5001,7 @@ function deriveRun(input) {
|
|
|
4729
5001
|
...version >= 16 ? { upperBound: true } : {}
|
|
4730
5002
|
});
|
|
4731
5003
|
const bg = backgroundMetrics(background, {
|
|
5004
|
+
...allComponents,
|
|
4732
5005
|
frameBudgetMs: compareMs,
|
|
4733
5006
|
countFrameMs: LIMIT_FRAME_MS,
|
|
4734
5007
|
...input.profileTopN === undefined ? {} : { topN: input.profileTopN },
|
|
@@ -4759,7 +5032,7 @@ function deriveRun(input) {
|
|
|
4759
5032
|
if (version >= 19) {
|
|
4760
5033
|
const events = input.events ?? { gc: [], appState: [] };
|
|
4761
5034
|
const launch = extra.get("mem.launch_mb");
|
|
4762
|
-
for (const [k, v] of [...endMetrics(markers, events, samplesInRun(samples, final.t0, final.t1), sdkWindows, launch, { manualRead: version >= 23 }), ...jsHeapMetrics(events, sdkWindows)])
|
|
5035
|
+
for (const [k, v] of [...endMetrics(markers, events, samplesInRun(samples, final.t0, final.t1), sdkWindows, launch, { manualRead: version >= 23, markerless: version >= 24 }), ...jsHeapMetrics(events, sdkWindows)])
|
|
4763
5036
|
extra.set(k, v);
|
|
4764
5037
|
}
|
|
4765
5038
|
const base = { run_id: final.run_id, arm: final.arm, block: final.block, valid: final.valid, invalid_reason: final.invalid_reason };
|
|
@@ -4962,44 +5235,24 @@ async function uploadRuns(deps, outDir, runs, opts) {
|
|
|
4962
5235
|
return { uploaded, skipped };
|
|
4963
5236
|
}
|
|
4964
5237
|
|
|
4965
|
-
// packages/present/dist/src/
|
|
4966
|
-
var
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
tap: "Tap",
|
|
4970
|
-
scroll: "Scroll",
|
|
4971
|
-
step: "Test step",
|
|
4972
|
-
journey: "Whole journey",
|
|
4973
|
-
background: "Background",
|
|
4974
|
-
after3s: "3 s after"
|
|
4975
|
-
};
|
|
4976
|
-
var KIND_WORD = {
|
|
4977
|
-
journey: PLACE_KIND_WORDS.journey,
|
|
4978
|
-
screen: PLACE_KIND_WORDS.screen,
|
|
4979
|
-
launch: PLACE_KIND_WORDS.launch,
|
|
4980
|
-
press: PLACE_KIND_WORDS.tap,
|
|
4981
|
-
drag: PLACE_KIND_WORDS.scroll,
|
|
4982
|
-
step: PLACE_KIND_WORDS.step,
|
|
4983
|
-
background: PLACE_KIND_WORDS.background
|
|
4984
|
-
};
|
|
4985
|
-
var BACKGROUND_SOURCES = {
|
|
4986
|
-
websocket: "data pushed over a WebSocket",
|
|
4987
|
-
launch: "work started at app launch",
|
|
4988
|
-
link: "work of a deep link outside its screen",
|
|
4989
|
-
touch: "work of a tap outside its window",
|
|
4990
|
-
input: "work of typing outside its window"
|
|
4991
|
-
};
|
|
4992
|
-
var OPEN_MARK = /#(1|repeat)$/;
|
|
5238
|
+
// packages/present/dist/src/capital.js
|
|
5239
|
+
var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
5240
|
+
|
|
5241
|
+
// packages/present/dist/src/placeWords.js
|
|
4993
5242
|
var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
|
|
4994
5243
|
var ORDINAL_SUFFIXES = ["th", "st", "nd", "rd"];
|
|
4995
5244
|
var LAST_TEEN = 13;
|
|
4996
5245
|
var FIRST_TEEN = 11;
|
|
4997
5246
|
var words = (s) => s.replace(/[._\-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
4998
5247
|
var cap = (s) => s ? s[0].toUpperCase() + s.slice(1) : s;
|
|
5248
|
+
var nameWords = (s) => cap(words(s));
|
|
4999
5249
|
var ordinal = (n) => {
|
|
5000
5250
|
const inTeens = n % 100 >= FIRST_TEEN && n % 100 <= LAST_TEEN;
|
|
5001
5251
|
return `${n}${inTeens ? "th" : ORDINAL_SUFFIXES[n % 10] ?? "th"}`;
|
|
5002
5252
|
};
|
|
5253
|
+
function routeFileName(path) {
|
|
5254
|
+
return path.split("/").filter((p) => p && !/^\(.*\)$/.test(p) && p !== "index").map(nameWords).join(" ") || "App start";
|
|
5255
|
+
}
|
|
5003
5256
|
function screenName(path) {
|
|
5004
5257
|
const parts = path.split("/").filter((p) => p && p !== "__root");
|
|
5005
5258
|
const plain = parts.filter((p) => !/^\(.*\)$/.test(p) && !/^\[.*\]$/.test(p) && p !== "index");
|
|
@@ -5011,50 +5264,7 @@ function screenName(path) {
|
|
|
5011
5264
|
const name = cap(words(leaf));
|
|
5012
5265
|
return group && !GENERIC_GROUPS.has(group) && !plain.slice(0, -1).length ? `${cap(words(group))} › ${name}` : name;
|
|
5013
5266
|
}
|
|
5014
|
-
|
|
5015
|
-
if (kind === "journey")
|
|
5016
|
-
return PLACE_KIND_WORDS.journey;
|
|
5017
|
-
if (kind === "other")
|
|
5018
|
-
return words(stem);
|
|
5019
|
-
const rest = stem.slice(stem.indexOf(":") + 1).replace(kind === "press" || kind === "drag" ? OPEN_MARK : /$^/, "");
|
|
5020
|
-
if (kind === "background")
|
|
5021
|
-
return `${KIND_WORD.background}: ${BACKGROUND_SOURCES[rest] ?? words(rest)}`;
|
|
5022
|
-
if (kind === "launch")
|
|
5023
|
-
return `${KIND_WORD.launch}: ${screenName(rest)}`;
|
|
5024
|
-
if (kind !== "screen")
|
|
5025
|
-
return `${KIND_WORD[kind]}: ${words(rest)}`;
|
|
5026
|
-
const visitMark = /^(.*)#(\d+)$/.exec(rest);
|
|
5027
|
-
const visit = visitMark ? Number(visitMark[2]) : 1;
|
|
5028
|
-
return `${PLACE_KIND_WORDS.screen}: ${screenName(visitMark ? visitMark[1] : rest)}${visit > 1 ? ` (${ordinal(visit)} visit)` : ""}`;
|
|
5029
|
-
}
|
|
5030
|
-
function windowName(id) {
|
|
5031
|
-
if (id === "flow")
|
|
5032
|
-
return PLACE_KIND_WORDS.journey;
|
|
5033
|
-
const ref = placeRef(id);
|
|
5034
|
-
const name = stemName(ref.stem, ref.kind);
|
|
5035
|
-
return ref.segment === "after3s" ? `${name} · ${PLACE_KIND_WORDS.after3s}` : name;
|
|
5036
|
-
}
|
|
5037
|
-
function placeKindWord(id) {
|
|
5038
|
-
if (id === "flow")
|
|
5039
|
-
return PLACE_KIND_WORDS.journey;
|
|
5040
|
-
const { kind } = placeRef(id);
|
|
5041
|
-
return kind === "other" ? "Window" : KIND_WORD[kind];
|
|
5042
|
-
}
|
|
5043
|
-
var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
|
|
5044
|
-
var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
|
|
5045
|
-
function placeLabel(id, screen) {
|
|
5046
|
-
if (id === "flow")
|
|
5047
|
-
return PLACE_KIND_WORDS.journey;
|
|
5048
|
-
const ref = placeRef(id);
|
|
5049
|
-
const after = ref.segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
|
|
5050
|
-
if (ref.kind === "press" || ref.kind === "drag" || ref.kind === "step") {
|
|
5051
|
-
const element = elementWords(ref.stem.slice(ref.stem.indexOf(":") + 1).replace(OPEN_MARK, ""));
|
|
5052
|
-
return `${KIND_WORD[ref.kind]} · “${element}”${screen ? ` · ${screen}` : ""}${after}`;
|
|
5053
|
-
}
|
|
5054
|
-
const name = windowName(ref.stem);
|
|
5055
|
-
const colon = name.indexOf(": ");
|
|
5056
|
-
return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
|
|
5057
|
-
}
|
|
5267
|
+
|
|
5058
5268
|
// packages/present/dist/src/states.js
|
|
5059
5269
|
var STATE_WORDS = {
|
|
5060
5270
|
worse: "Worse",
|
|
@@ -5064,7 +5274,9 @@ var STATE_WORDS = {
|
|
|
5064
5274
|
"cant-tell": "Can’t tell"
|
|
5065
5275
|
};
|
|
5066
5276
|
var INFO_WORDS = { heat: "Phone heated up" };
|
|
5277
|
+
var isWorseState = (state) => state === "worse";
|
|
5067
5278
|
var NOT_BLOCKING = "not blocking";
|
|
5279
|
+
|
|
5068
5280
|
// packages/present/dist/src/values.js
|
|
5069
5281
|
var NO_VALUE = "–";
|
|
5070
5282
|
var FALLBACK_DECIMALS = 1;
|
|
@@ -5120,6 +5332,143 @@ function fieldValueText(field, v) {
|
|
|
5120
5332
|
const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
|
|
5121
5333
|
return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
|
|
5122
5334
|
}
|
|
5335
|
+
|
|
5336
|
+
// packages/present/dist/src/rowWords.js
|
|
5337
|
+
var GONE_TAILS = {
|
|
5338
|
+
branch: { notInRun: "on main, not in this run", removed: "on main, not rendered in this run" },
|
|
5339
|
+
journey: { notInRun: "in the sessions before, not in the newest session", removed: "in the sessions before, not rendered in the newest session" }
|
|
5340
|
+
};
|
|
5341
|
+
var ROW_STATE_WORDS = {
|
|
5342
|
+
new: "New in this PR: not compared with main",
|
|
5343
|
+
notInRun: upperFirst(GONE_TAILS.branch.notInRun),
|
|
5344
|
+
removed: upperFirst(GONE_TAILS.branch.removed)
|
|
5345
|
+
};
|
|
5346
|
+
var JOURNEY_ROW_WORDS = {
|
|
5347
|
+
new: "New in the newest session: not compared with the sessions before",
|
|
5348
|
+
notInRun: upperFirst(GONE_TAILS.journey.notInRun),
|
|
5349
|
+
removed: upperFirst(GONE_TAILS.journey.removed)
|
|
5350
|
+
};
|
|
5351
|
+
function placeShownName(p, o = {}) {
|
|
5352
|
+
const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
|
|
5353
|
+
const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
|
|
5354
|
+
return o.onScreen ? `${named} on ${o.onScreen}` : named;
|
|
5355
|
+
}
|
|
5356
|
+
var libraryViewName = (kind, hostType) => `${kind === "drag" ? "Scroll" : "Tap"} outside app components (${hostType})`;
|
|
5357
|
+
function componentShownName(name, file) {
|
|
5358
|
+
if (name !== "default")
|
|
5359
|
+
return name;
|
|
5360
|
+
const path = file.replace(/\.[^.\/]+$/, "");
|
|
5361
|
+
const route = /^(?:src\/)?app\/(.*)$/.exec(path)?.[1];
|
|
5362
|
+
const parts = path.split("/");
|
|
5363
|
+
const stem = parts.at(-1) ?? "";
|
|
5364
|
+
if (route !== undefined && stem !== "_layout")
|
|
5365
|
+
return routeFileName(route);
|
|
5366
|
+
const label = stem === "index" || stem === "_layout" ? parts.at(-2) ?? stem : stem;
|
|
5367
|
+
return nameWords(label);
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5370
|
+
// packages/present/dist/src/places.js
|
|
5371
|
+
var PLACE_KIND_WORDS = {
|
|
5372
|
+
screen: "Screen",
|
|
5373
|
+
launch: "Launch screen",
|
|
5374
|
+
tap: "Tap",
|
|
5375
|
+
scroll: "Scroll",
|
|
5376
|
+
step: "Test step",
|
|
5377
|
+
journey: "Whole journey",
|
|
5378
|
+
background: "Background",
|
|
5379
|
+
after3s: "3 s after"
|
|
5380
|
+
};
|
|
5381
|
+
var KIND_WORD = {
|
|
5382
|
+
journey: PLACE_KIND_WORDS.journey,
|
|
5383
|
+
screen: PLACE_KIND_WORDS.screen,
|
|
5384
|
+
launch: PLACE_KIND_WORDS.launch,
|
|
5385
|
+
press: PLACE_KIND_WORDS.tap,
|
|
5386
|
+
drag: PLACE_KIND_WORDS.scroll,
|
|
5387
|
+
step: PLACE_KIND_WORDS.step,
|
|
5388
|
+
background: PLACE_KIND_WORDS.background
|
|
5389
|
+
};
|
|
5390
|
+
var BACKGROUND_SOURCES = {
|
|
5391
|
+
websocket: "data pushed over a WebSocket",
|
|
5392
|
+
launch: "work started at app launch",
|
|
5393
|
+
link: "work of a deep link outside its screen",
|
|
5394
|
+
touch: "work of a tap outside its window",
|
|
5395
|
+
input: "work of typing outside its window"
|
|
5396
|
+
};
|
|
5397
|
+
var OPEN_MARK = /#(1|repeat)$/;
|
|
5398
|
+
function placeIdShownName(id, o = {}) {
|
|
5399
|
+
const p = placeParts(id);
|
|
5400
|
+
if (!p)
|
|
5401
|
+
return null;
|
|
5402
|
+
const component = (part) => ({ name: part.name, file: part.file ?? "" });
|
|
5403
|
+
const screen = p.screen.name === "(no screen)" ? "" : p.screen.name === "(outside app components)" ? screenName(p.screen.file ?? "") : componentShownName(p.screen.name, p.screen.file ?? "");
|
|
5404
|
+
if (p.kind === "screen" || p.kind === "launch")
|
|
5405
|
+
return screen + (p.kind === "screen" && typeof p.visit === "number" && p.visit > 1 ? ` (${ordinal(p.visit)} visit)` : "");
|
|
5406
|
+
return placeShownName({ kind: p.kind, target: p.outsideHost === null ? component(p.target) : null, parent: p.parent ? component(p.parent) : null, k: p.k, ...p.outsideHost === null ? {} : { host: p.outsideHost } }, { onScreen: o.onScreen === false ? undefined : screen });
|
|
5407
|
+
}
|
|
5408
|
+
var isLibraryPlace = (id) => placeParts(id)?.outsideHost != null;
|
|
5409
|
+
function stemName(stem, kind) {
|
|
5410
|
+
const shown = placeIdShownName(stem);
|
|
5411
|
+
if (shown !== null)
|
|
5412
|
+
return isLibraryPlace(stem) ? shown : `${KIND_WORD[kind]}: ${shown}`;
|
|
5413
|
+
if (kind === "journey")
|
|
5414
|
+
return PLACE_KIND_WORDS.journey;
|
|
5415
|
+
if (kind === "other")
|
|
5416
|
+
return words(stem);
|
|
5417
|
+
const rest = stem.slice(stem.indexOf(":") + 1).replace(kind === "press" || kind === "drag" ? OPEN_MARK : /$^/, "");
|
|
5418
|
+
if (kind === "background")
|
|
5419
|
+
return `${KIND_WORD.background}: ${BACKGROUND_SOURCES[rest] ?? words(rest)}`;
|
|
5420
|
+
if (kind === "launch")
|
|
5421
|
+
return `${KIND_WORD.launch}: ${screenName(rest)}`;
|
|
5422
|
+
if (kind !== "screen")
|
|
5423
|
+
return `${KIND_WORD[kind]}: ${words(rest)}`;
|
|
5424
|
+
const visitMark = /^(.*)#(\d+)$/.exec(rest);
|
|
5425
|
+
const visit = visitMark ? Number(visitMark[2]) : 1;
|
|
5426
|
+
return `${PLACE_KIND_WORDS.screen}: ${screenName(visitMark ? visitMark[1] : rest)}${visit > 1 ? ` (${ordinal(visit)} visit)` : ""}`;
|
|
5427
|
+
}
|
|
5428
|
+
function windowName(id) {
|
|
5429
|
+
if (id === "flow")
|
|
5430
|
+
return PLACE_KIND_WORDS.journey;
|
|
5431
|
+
const ref = placeRef(id);
|
|
5432
|
+
const name = stemName(ref.stem, ref.kind);
|
|
5433
|
+
return ref.segment === "after3s" ? `${name} · ${PLACE_KIND_WORDS.after3s}` : name;
|
|
5434
|
+
}
|
|
5435
|
+
function placeKindWord(id) {
|
|
5436
|
+
if (id === "flow")
|
|
5437
|
+
return PLACE_KIND_WORDS.journey;
|
|
5438
|
+
const { kind } = placeRef(id);
|
|
5439
|
+
return kind === "other" ? "Window" : KIND_WORD[kind];
|
|
5440
|
+
}
|
|
5441
|
+
var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
|
|
5442
|
+
var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
|
|
5443
|
+
function placeLabel(id, screen) {
|
|
5444
|
+
if (id === "flow")
|
|
5445
|
+
return PLACE_KIND_WORDS.journey;
|
|
5446
|
+
const ref = placeRef(id);
|
|
5447
|
+
const after = ref.segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
|
|
5448
|
+
const shown = placeIdShownName(ref.stem);
|
|
5449
|
+
if (shown !== null)
|
|
5450
|
+
return isLibraryPlace(ref.stem) ? `${shown}${after}` : `${KIND_WORD[ref.kind]} · ${shown}${after}`;
|
|
5451
|
+
if (ref.kind === "press" || ref.kind === "drag" || ref.kind === "step") {
|
|
5452
|
+
const element = elementWords(ref.stem.slice(ref.stem.indexOf(":") + 1).replace(OPEN_MARK, ""));
|
|
5453
|
+
return `${KIND_WORD[ref.kind]} · “${element}”${screen ? ` · ${screen}` : ""}${after}`;
|
|
5454
|
+
}
|
|
5455
|
+
const name = windowName(ref.stem);
|
|
5456
|
+
const colon = name.indexOf(": ");
|
|
5457
|
+
return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
|
|
5458
|
+
}
|
|
5459
|
+
// packages/present/dist/src/words.js
|
|
5460
|
+
var REASON_WORDS = {
|
|
5461
|
+
"no-baseline": "there are no main runs on this phone and journey yet",
|
|
5462
|
+
"too-few-runs": "there are too few valid runs on a side",
|
|
5463
|
+
"judged-measures-missing": "the judged measures are missing on a side",
|
|
5464
|
+
"measured-differently": "the sides were measured differently",
|
|
5465
|
+
"runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
|
|
5466
|
+
"facts-version-mismatch": "the sides were derived with different versions",
|
|
5467
|
+
"not-judged-on-simulators": "timing is not judged on simulators",
|
|
5468
|
+
"platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
|
|
5469
|
+
"not-enough-main": "main does not have enough sessions on this phone and journey yet"
|
|
5470
|
+
};
|
|
5471
|
+
var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
|
|
5123
5472
|
// packages/present/dist/src/stability.js
|
|
5124
5473
|
var UNSTABLE_WORDS = {
|
|
5125
5474
|
short: "main varies too much here",
|
|
@@ -5128,6 +5477,92 @@ var UNSTABLE_WORDS = {
|
|
|
5128
5477
|
ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
|
|
5129
5478
|
what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
|
|
5130
5479
|
};
|
|
5480
|
+
// packages/present/dist/src/present.js
|
|
5481
|
+
var ASPECT_WORDS = {
|
|
5482
|
+
renderer: "React renderer",
|
|
5483
|
+
reactExport: "React export",
|
|
5484
|
+
causeSplit: "work split by its cause",
|
|
5485
|
+
windowModel: "window model",
|
|
5486
|
+
screenWindowModel: "screen window model",
|
|
5487
|
+
cpuSource: "CPU source",
|
|
5488
|
+
sdk: "SDK version",
|
|
5489
|
+
driver: "driver",
|
|
5490
|
+
driverVersion: "driver version",
|
|
5491
|
+
journeyVersion: "journey version",
|
|
5492
|
+
actionGc: "memory clean-up before each step",
|
|
5493
|
+
endGc: "memory clean-up at the journey end",
|
|
5494
|
+
cpuProfiling: "CPU profiling"
|
|
5495
|
+
};
|
|
5496
|
+
// packages/present/dist/src/main-history.js
|
|
5497
|
+
var MAIN_TIER_WORDS = {
|
|
5498
|
+
same: "In norm",
|
|
5499
|
+
better: STATE_WORDS.better,
|
|
5500
|
+
cant: STATE_WORDS["cant-tell"],
|
|
5501
|
+
slight: STATE_WORDS.warning,
|
|
5502
|
+
worse: STATE_WORDS.worse
|
|
5503
|
+
};
|
|
5504
|
+
var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
|
|
5505
|
+
function mainCantWords(reason, config) {
|
|
5506
|
+
if (!reason)
|
|
5507
|
+
return "";
|
|
5508
|
+
return reason.kind === "noisy_main" ? "main’s runs vary too much there" : `main has ${reason.earlier_sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${reason.since_label ? ` on ${reason.since_label}` : ""}`;
|
|
5509
|
+
}
|
|
5510
|
+
var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
|
|
5511
|
+
function mainPlaceReasonWords(reason, config) {
|
|
5512
|
+
switch (reason.kind) {
|
|
5513
|
+
case "no_baseline":
|
|
5514
|
+
case "noisy_main":
|
|
5515
|
+
return mainCantWords(reason, config);
|
|
5516
|
+
case "higher":
|
|
5517
|
+
return "Higher than main’s earlier sessions";
|
|
5518
|
+
case "lower":
|
|
5519
|
+
return "Lower than main’s earlier sessions";
|
|
5520
|
+
case "missing_value":
|
|
5521
|
+
return REASON_WORDS["runs-unknown"];
|
|
5522
|
+
case "measured_differently":
|
|
5523
|
+
return REASON_WORDS["measured-differently"];
|
|
5524
|
+
case "core":
|
|
5525
|
+
return reason.codes.map((code) => REASON_WORDS[code]).join("; ");
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
|
|
5529
|
+
var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
|
|
5530
|
+
var SHORT_ASPECT_WORDS = {
|
|
5531
|
+
...ASPECT_WORDS,
|
|
5532
|
+
sdk: "SDK",
|
|
5533
|
+
renderer: "Renderer",
|
|
5534
|
+
endGc: "End GC",
|
|
5535
|
+
causeSplit: "Cause split",
|
|
5536
|
+
driver: "Driver",
|
|
5537
|
+
driverVersion: "Driver",
|
|
5538
|
+
journeyVersion: "Journey"
|
|
5539
|
+
};
|
|
5540
|
+
function mainSessionCantWords(places, reason, config) {
|
|
5541
|
+
return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
|
|
5542
|
+
}
|
|
5543
|
+
var KIND_PLURAL = {
|
|
5544
|
+
screen: "screens",
|
|
5545
|
+
press: "taps",
|
|
5546
|
+
drag: "scrolls",
|
|
5547
|
+
launch: "launch screens",
|
|
5548
|
+
step: "test steps",
|
|
5549
|
+
background: "background work",
|
|
5550
|
+
other: "other places"
|
|
5551
|
+
};
|
|
5552
|
+
var listWords = (kinds) => {
|
|
5553
|
+
const words = kinds.map((k) => KIND_PLURAL[k] ?? "other places");
|
|
5554
|
+
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
5555
|
+
};
|
|
5556
|
+
function mainCutWords(v) {
|
|
5557
|
+
const parts = [];
|
|
5558
|
+
if (v.cuts.length)
|
|
5559
|
+
parts.push(`Starts a new ${v.cuts.join(" and ")} baseline.`);
|
|
5560
|
+
if (v.partial_render) {
|
|
5561
|
+
const cut = listWords(v.partial_render.cut);
|
|
5562
|
+
parts.push(`${cut[0].toUpperCase()}${cut.slice(1)} start a new render baseline; ${listWords(v.partial_render.kept)} keep theirs.`);
|
|
5563
|
+
}
|
|
5564
|
+
return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
|
|
5565
|
+
}
|
|
5131
5566
|
// apps/agent/src/summary.ts
|
|
5132
5567
|
var fieldWord = (field) => measureDef(field)?.short ?? field;
|
|
5133
5568
|
function plainMetric(metric) {
|
|
@@ -5209,7 +5644,13 @@ function movedCounts(sc) {
|
|
|
5209
5644
|
}
|
|
5210
5645
|
var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
|
|
5211
5646
|
var addCounts = (xs) => xs.reduce((a, c) => ({ worse: a.worse + c.worse, warnings: a.warnings + c.warnings, better: a.better + c.better, noChange: a.noChange + c.noChange, inconclusive: a.inconclusive + c.inconclusive }), { worse: 0, warnings: 0, better: 0, noChange: 0, inconclusive: 0 });
|
|
5212
|
-
var setupLine = (h) =>
|
|
5647
|
+
var setupLine = (h) => {
|
|
5648
|
+
const parts = [
|
|
5649
|
+
h?.measured_differently?.length ? `Measured differently (${h.measured_differently.join(", ")}) — ${NOT_BLOCKING}` : "",
|
|
5650
|
+
h?.waiting_for_reprocess ? waitingWords(h.waiting_for_reprocess) : ""
|
|
5651
|
+
].filter(Boolean);
|
|
5652
|
+
return parts.length ? parts.join("; ") : null;
|
|
5653
|
+
};
|
|
5213
5654
|
var keyHeader = (t) => ({ "x-keepsnappy-key": t.key });
|
|
5214
5655
|
async function createRunSet(t, body) {
|
|
5215
5656
|
const r = await call(t.fetch ?? fetch, t.url, "/v1/run-sets", keyHeader(t), body);
|
|
@@ -8827,15 +9268,18 @@ ${r.stderr}` : ""}`);
|
|
|
8827
9268
|
}
|
|
8828
9269
|
|
|
8829
9270
|
// apps/agent/src/run.ts
|
|
8830
|
-
|
|
9271
|
+
var matchesMetric = (prefix, metric) => metric === prefix || metric.startsWith(`${prefix}.`);
|
|
9272
|
+
function renameMetrics(rows, rename, warn = console.warn) {
|
|
9273
|
+
for (const key of Object.keys(rename)) {
|
|
9274
|
+
if (!rows.some((r) => [key, `step:${key}`].some((prefix) => matchesMetric(prefix, r.metric))))
|
|
9275
|
+
warn(`steps.rename: key "${key}" matches no metric.`);
|
|
9276
|
+
}
|
|
8831
9277
|
const pairs = Object.entries(rename).flatMap(([a, b]) => [[a, b], [`step:${a}`, `step:${b}`]]);
|
|
8832
9278
|
if (pairs.length === 0)
|
|
8833
9279
|
return [...rows];
|
|
8834
9280
|
return rows.map((r) => {
|
|
8835
9281
|
for (const [from, to] of pairs) {
|
|
8836
|
-
if (r.metric
|
|
8837
|
-
return { ...r, metric: to };
|
|
8838
|
-
if (r.metric.startsWith(`${from}.`))
|
|
9282
|
+
if (matchesMetric(from, r.metric))
|
|
8839
9283
|
return { ...r, metric: `${to}${r.metric.slice(from.length)}` };
|
|
8840
9284
|
}
|
|
8841
9285
|
return r;
|
|
@@ -8853,7 +9297,7 @@ async function keepsnappyRun(deps, source, opts) {
|
|
|
8853
9297
|
...opts.markers ? { markers: opts.markers } : {},
|
|
8854
9298
|
...opts.clockOffsetMs === undefined ? {} : { clockOffsetMs: opts.clockOffsetMs }
|
|
8855
9299
|
});
|
|
8856
|
-
const rows = renameMetrics(raw, opts.config.steps.rename);
|
|
9300
|
+
const rows = renameMetrics(raw, opts.config.steps.rename, deps.log);
|
|
8857
9301
|
await deps.fs.writeFile(join8(opts.outDir, "metrics.csv"), toCsv(rows));
|
|
8858
9302
|
const summary = summarize({ runs, outDir: opts.outDir });
|
|
8859
9303
|
return { runs, rows, summary };
|
|
@@ -9272,10 +9716,20 @@ var nodeSpawner = {
|
|
|
9272
9716
|
let stdout = "";
|
|
9273
9717
|
let stderr = "";
|
|
9274
9718
|
let error;
|
|
9719
|
+
let timeout;
|
|
9720
|
+
let killTimeout;
|
|
9721
|
+
let closed = false;
|
|
9275
9722
|
const child = spawn(cmd, [...args], {
|
|
9276
|
-
env: { ...process.env, ...opts?.env }
|
|
9277
|
-
...opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}
|
|
9723
|
+
env: { ...process.env, ...opts?.env }
|
|
9278
9724
|
});
|
|
9725
|
+
if (opts?.timeoutMs) {
|
|
9726
|
+
timeout = setTimeout(() => {
|
|
9727
|
+
error = `timed out after ${opts.timeoutMs}ms`;
|
|
9728
|
+
child.kill("SIGTERM");
|
|
9729
|
+
if (!closed)
|
|
9730
|
+
killTimeout = setTimeout(() => child.kill("SIGKILL"), opts.killAfterMs ?? 120000);
|
|
9731
|
+
}, opts.timeoutMs);
|
|
9732
|
+
}
|
|
9279
9733
|
child.stdout.on("data", (d) => {
|
|
9280
9734
|
stdout += d.toString();
|
|
9281
9735
|
opts?.onOutput?.(d.toString());
|
|
@@ -9286,8 +9740,11 @@ var nodeSpawner = {
|
|
|
9286
9740
|
});
|
|
9287
9741
|
child.on("error", (e) => error = e.message);
|
|
9288
9742
|
child.on("close", (code, signal) => {
|
|
9289
|
-
|
|
9290
|
-
|
|
9743
|
+
closed = true;
|
|
9744
|
+
clearTimeout(timeout);
|
|
9745
|
+
clearTimeout(killTimeout);
|
|
9746
|
+
if (signal)
|
|
9747
|
+
error = error ? `${error}; killed by ${signal}` : `killed by ${signal}`;
|
|
9291
9748
|
resolve({ code, stdout, stderr, ...error ? { error } : {} });
|
|
9292
9749
|
});
|
|
9293
9750
|
})
|
|
@@ -9330,7 +9787,7 @@ async function aa(deps, outDir, opts = {}) {
|
|
|
9330
9787
|
}
|
|
9331
9788
|
|
|
9332
9789
|
// apps/agent/src/farm.ts
|
|
9333
|
-
import { dirname as dirname3, join as
|
|
9790
|
+
import { dirname as dirname3, join as join13, resolve as resolve5 } from "node:path";
|
|
9334
9791
|
|
|
9335
9792
|
// packages/appium-driver/dist/src/links.js
|
|
9336
9793
|
var ALERT_POLL_MS = 250;
|
|
@@ -9628,18 +10085,13 @@ class WdError extends Error {
|
|
|
9628
10085
|
this.wdError = wdError;
|
|
9629
10086
|
}
|
|
9630
10087
|
}
|
|
9631
|
-
// packages/appium-driver/dist/src/session.js
|
|
9632
|
-
var SDK_FLUSH_WORST_CASE_MS = 315000;
|
|
9633
|
-
var FLUSH_MARGIN_MS = 30000;
|
|
9634
|
-
var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
|
|
9635
|
-
var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
|
|
9636
10088
|
// packages/appium-driver/dist/src/version.js
|
|
9637
10089
|
import { createHash } from "node:crypto";
|
|
9638
10090
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
9639
10091
|
import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
|
|
9640
10092
|
import { fileURLToPath } from "node:url";
|
|
9641
10093
|
function driverVersion() {
|
|
9642
|
-
const built = "0.1.0-alpha.
|
|
10094
|
+
const built = "0.1.0-alpha.16";
|
|
9643
10095
|
if (built)
|
|
9644
10096
|
return built;
|
|
9645
10097
|
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
@@ -9688,8 +10140,93 @@ function journeyVersion(modulePath) {
|
|
|
9688
10140
|
hash.update(key).update("\x00").update(text).update("\x00");
|
|
9689
10141
|
return hash.digest("hex").slice(0, 12);
|
|
9690
10142
|
}
|
|
10143
|
+
|
|
10144
|
+
// packages/appium-driver/dist/src/session.js
|
|
10145
|
+
var SDK_FLUSH_WORST_CASE_MS = 315000;
|
|
10146
|
+
var FLUSH_MARGIN_MS = 30000;
|
|
10147
|
+
var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
|
|
10148
|
+
var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
|
|
10149
|
+
// packages/appium-driver/dist/src/gestures.js
|
|
10150
|
+
var densities = new WeakMap;
|
|
10151
|
+
// apps/agent/src/device-model.ts
|
|
10152
|
+
import { tmpdir } from "node:os";
|
|
10153
|
+
import { join as join12 } from "node:path";
|
|
10154
|
+
function parseDevicectlList(json) {
|
|
10155
|
+
let data;
|
|
10156
|
+
try {
|
|
10157
|
+
data = JSON.parse(json);
|
|
10158
|
+
} catch {
|
|
10159
|
+
return [];
|
|
10160
|
+
}
|
|
10161
|
+
const devices = Array.isArray(data.result?.devices) ? data.result.devices : [];
|
|
10162
|
+
return devices.flatMap((d) => {
|
|
10163
|
+
const hw = d.hardwareProperties ?? {};
|
|
10164
|
+
const conn = d.connectionProperties ?? {};
|
|
10165
|
+
if (typeof hw.productType !== "string" || !hw.productType)
|
|
10166
|
+
return [];
|
|
10167
|
+
const text = (v) => typeof v === "string" ? v : "";
|
|
10168
|
+
return [{ productType: hw.productType, udid: text(hw.udid), identifier: text(d.identifier), serial: text(hw.serialNumber), connected: conn.tunnelState === "connected" }];
|
|
10169
|
+
});
|
|
10170
|
+
}
|
|
10171
|
+
var parseSimulators = (json) => {
|
|
10172
|
+
try {
|
|
10173
|
+
const data = JSON.parse(json);
|
|
10174
|
+
return Object.values(data.devices ?? {}).flat();
|
|
10175
|
+
} catch {
|
|
10176
|
+
return [];
|
|
10177
|
+
}
|
|
10178
|
+
};
|
|
10179
|
+
var withSimulatorWord = (name) => /simulator/i.test(name) ? name : `${name} Simulator`;
|
|
10180
|
+
var simulatorModel = (s) => withSimulatorWord(s.name);
|
|
10181
|
+
async function androidModel(deps, id) {
|
|
10182
|
+
const r = await deps.spawner.run("adb", [...id ? ["-s", id] : [], "shell", "getprop", "ro.product.model"]);
|
|
10183
|
+
const model = r.code === 0 ? (r.stdout.split(`
|
|
10184
|
+
`)[0] ?? "").trim() : "";
|
|
10185
|
+
return model || null;
|
|
10186
|
+
}
|
|
10187
|
+
async function simulators(deps, id) {
|
|
10188
|
+
const r = await deps.spawner.run("xcrun", ["simctl", "list", "devices", ...id ? [] : ["booted"], "-j"]);
|
|
10189
|
+
if (r.code !== 0)
|
|
10190
|
+
return [];
|
|
10191
|
+
return parseSimulators(r.stdout).filter((s) => id ? s.udid === id : s.state === "Booted");
|
|
10192
|
+
}
|
|
10193
|
+
async function phones(deps) {
|
|
10194
|
+
const out = join12(tmpdir(), `keepsnappy-devicectl-${deps.clock.now()}.json`);
|
|
10195
|
+
try {
|
|
10196
|
+
const r = await deps.spawner.run("xcrun", ["devicectl", "list", "devices", "--quiet", "--json-output", out]);
|
|
10197
|
+
const text = r.code === 0 ? await deps.fs.readFile(out).catch(() => "") : "";
|
|
10198
|
+
return parseDevicectlList(text);
|
|
10199
|
+
} finally {
|
|
10200
|
+
await deps.spawner.run("rm", ["-f", out]);
|
|
10201
|
+
}
|
|
10202
|
+
}
|
|
10203
|
+
async function iosModel(deps, id) {
|
|
10204
|
+
const sims = await simulators(deps, id);
|
|
10205
|
+
if (id) {
|
|
10206
|
+
if (sims.length === 1)
|
|
10207
|
+
return simulatorModel(sims[0]);
|
|
10208
|
+
const found = (await phones(deps)).filter((p) => [p.udid, p.identifier, p.serial].some((x) => x.toLowerCase() === id.toLowerCase()));
|
|
10209
|
+
return found.length === 1 ? found[0].productType : null;
|
|
10210
|
+
}
|
|
10211
|
+
const connected = (await phones(deps)).filter((p) => p.connected);
|
|
10212
|
+
if (sims.length + connected.length !== 1)
|
|
10213
|
+
return null;
|
|
10214
|
+
return sims[0] ? simulatorModel(sims[0]) : connected[0].productType;
|
|
10215
|
+
}
|
|
10216
|
+
async function detectDeviceModel(deps, q) {
|
|
10217
|
+
if (q.platform === "android")
|
|
10218
|
+
return androidModel(deps, q.deviceId);
|
|
10219
|
+
if (q.platform === "ios")
|
|
10220
|
+
return iosModel(deps, q.deviceId);
|
|
10221
|
+
return null;
|
|
10222
|
+
}
|
|
10223
|
+
async function detectSimulator(deps, udid) {
|
|
10224
|
+
const sims = await simulators(deps, udid);
|
|
10225
|
+
return sims.length === 1 ? simulatorModel(sims[0]) : null;
|
|
10226
|
+
}
|
|
10227
|
+
|
|
9691
10228
|
// apps/agent/src/slack.ts
|
|
9692
|
-
var COLOR = { error: "#
|
|
10229
|
+
var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
|
|
9693
10230
|
var MAX_ROWS = 3;
|
|
9694
10231
|
var NAME_W = 30;
|
|
9695
10232
|
var COLUMN_GAP = 1;
|
|
@@ -9719,7 +10256,7 @@ var isBetter = (i) => i.state === "better";
|
|
|
9719
10256
|
var differs = (sc) => sc?.headline?.measured_differently ?? [];
|
|
9720
10257
|
var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
|
|
9721
10258
|
var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
|
|
9722
|
-
var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : "\uD83D\
|
|
10259
|
+
var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : i.grade !== "warn" ? "⚪" : "\uD83D\uDFE1";
|
|
9723
10260
|
var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
|
|
9724
10261
|
var right = (s, w) => s.padStart(w);
|
|
9725
10262
|
var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
|
|
@@ -9785,6 +10322,10 @@ function slackMessage(s, ctx) {
|
|
|
9785
10322
|
const warningRows = worse.filter((i) => i.grade === "warn");
|
|
9786
10323
|
const errors = placeCount(errorRows);
|
|
9787
10324
|
const warns = placeCount(worse) - errors;
|
|
10325
|
+
const red = errorRows.some((i) => isRed(i, scenarioOf(i)));
|
|
10326
|
+
const notBlocking = errors > 0 && !red;
|
|
10327
|
+
const worseMark = red ? "\uD83D\uDD34" : warns > 0 ? "\uD83D\uDFE1" : "⚪";
|
|
10328
|
+
const worseColor = red ? COLOR.error : warns > 0 ? COLOR.warn : COLOR.none;
|
|
9788
10329
|
if (isPr) {
|
|
9789
10330
|
if (!worse.length) {
|
|
9790
10331
|
if (!heat.length)
|
|
@@ -9793,12 +10334,10 @@ function slackMessage(s, ctx) {
|
|
|
9793
10334
|
const blocks = [section(`*${head}*`), ...heat, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0].dashboard_url ?? s.dashboard_url}|View the comparison>`)];
|
|
9794
10335
|
return { text: head, attachments: [{ color: COLOR.none, blocks }] };
|
|
9795
10336
|
}
|
|
9796
|
-
const red = worse.some((i) => isRed(i, scenarioOf(i)));
|
|
9797
10337
|
const graded = worse.some((i) => i.grade);
|
|
9798
|
-
const notBlocking = errors > 0 && !red;
|
|
9799
10338
|
const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
|
|
9800
|
-
const counts = graded ? [errors ? `${places(STATE_WORDS.worse, errors)}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? places(STATE_WORDS.warning, warns) : ""].filter(Boolean).join(", ") : places(STATE_WORDS.worse, placeCount(worse))
|
|
9801
|
-
const head = `${
|
|
10339
|
+
const counts = graded ? [errors ? `${places(STATE_WORDS.worse, errors)}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? places(STATE_WORDS.warning, warns) : ""].filter(Boolean).join(", ") : `${places(STATE_WORDS.worse, placeCount(worse))}${notBlocking ? ` (${NOT_BLOCKING})` : ""}`;
|
|
10340
|
+
const head = `${worseMark} ${ctx.branch}: ${counts}`;
|
|
9802
10341
|
const blocks = [section(`*${head}*`)];
|
|
9803
10342
|
const groups = new Map;
|
|
9804
10343
|
for (const i of worse)
|
|
@@ -9814,7 +10353,8 @@ function slackMessage(s, ctx) {
|
|
|
9814
10353
|
const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
|
|
9815
10354
|
const note = differs(sc).length ? `
|
|
9816
10355
|
_Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
|
|
9817
|
-
|
|
10356
|
+
const nonblocking = !scenarioBlocks(sc) && rows.some((i) => i.grade !== "warn") && !differs(sc).length ? ` · ${NOT_BLOCKING}` : "";
|
|
10357
|
+
blocks.push(section(`*${sc?.scenario ?? "journey"}*${nonblocking} · ${phone(sc)}${runs}${note}
|
|
9818
10358
|
${prTable(rows, sc)}`));
|
|
9819
10359
|
}
|
|
9820
10360
|
const more = worse.length - shown;
|
|
@@ -9822,15 +10362,15 @@ ${prTable(rows, sc)}`));
|
|
|
9822
10362
|
if (commit)
|
|
9823
10363
|
blocks.push(context(commit));
|
|
9824
10364
|
blocks.push(section(`\uD83D\uDC49 <${groups.keys().next().value?.dashboard_url ?? s.dashboard_url}|View the comparison>${more > 0 ? ` · ${more} more` : ""}`));
|
|
9825
|
-
return { text: head, attachments: [{ color:
|
|
10365
|
+
return { text: head, attachments: [{ color: worseColor, blocks }] };
|
|
9826
10366
|
}
|
|
9827
10367
|
const improved = better.length > 0 || v.state === "better";
|
|
9828
10368
|
if (!worse.length && !improved && !ctx.always && !heat.length)
|
|
9829
10369
|
return null;
|
|
9830
|
-
const parts = [errors ? `${errors} ${STATE_WORDS.worse.toLowerCase()}` : "", warns ? `${warns} ${STATE_WORDS.warning.toLowerCase()}` : "", better.length ? `${placeCount(better)} ${STATE_WORDS.better.toLowerCase()}` : ""].filter(Boolean);
|
|
10370
|
+
const parts = [errors ? `${errors} ${STATE_WORDS.worse.toLowerCase()}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? `${warns} ${STATE_WORDS.warning.toLowerCase()}` : "", better.length ? `${placeCount(better)} ${STATE_WORDS.better.toLowerCase()}` : ""].filter(Boolean);
|
|
9831
10371
|
const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
|
|
9832
10372
|
const status = parts.length ? `${ctx.branch}: ${parts.join(", ")}` : !improved && heat.length && !ctx.always ? `${ctx.branch}: ${INFO_WORDS.heat}` : quiet.startsWith("Main: ") && ctx.branch === "main" ? quiet : `${ctx.branch}: ${quiet}`;
|
|
9833
|
-
const head = `${worse.length ?
|
|
10373
|
+
const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
|
|
9834
10374
|
const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
|
|
9835
10375
|
const blocks = [section(`*${head}*
|
|
9836
10376
|
${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
|
|
@@ -9841,7 +10381,7 @@ ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
|
|
|
9841
10381
|
break;
|
|
9842
10382
|
shown++;
|
|
9843
10383
|
const sc = scenarioOf(i);
|
|
9844
|
-
blocks.push(section(`*${title}
|
|
10384
|
+
blocks.push(section(`*${title}*${i.grade !== "warn" && isWorse(i) && !scenarioBlocks(sc) ? ` (${NOT_BLOCKING})` : ""} · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
|
|
9845
10385
|
${mainCard(i)}`));
|
|
9846
10386
|
}
|
|
9847
10387
|
}
|
|
@@ -9850,7 +10390,7 @@ ${mainCard(i)}`));
|
|
|
9850
10390
|
if (commit)
|
|
9851
10391
|
blocks.push(context(commit));
|
|
9852
10392
|
blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
|
|
9853
|
-
const color = worse.length ?
|
|
10393
|
+
const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
|
|
9854
10394
|
return { text: head, attachments: [{ color, blocks }] };
|
|
9855
10395
|
}
|
|
9856
10396
|
async function postToSlack(url, payload, fetchImpl = fetch) {
|
|
@@ -9997,9 +10537,20 @@ async function farmOpen(deps, f, rt) {
|
|
|
9997
10537
|
const git = gitContext({ ...f.branch ? { branch: f.branch } : {}, ...f.commit ? { commit: f.commit } : {} }, rt.env);
|
|
9998
10538
|
if (!git.branch)
|
|
9999
10539
|
problems.push("--branch is required outside GitHub Actions");
|
|
10000
|
-
|
|
10540
|
+
let provider = f["device-provider"] ?? "browserstack";
|
|
10541
|
+
let model = f["device-model"]?.trim();
|
|
10542
|
+
const simulator = f.platform === "ios" && provider !== "browserstack" && f["device-id"]?.trim() ? await detectSimulator(deps, f["device-id"].trim()) : null;
|
|
10543
|
+
if (simulator) {
|
|
10544
|
+
provider = "simulator";
|
|
10545
|
+
model = model ? withSimulatorWord(model) : simulator;
|
|
10546
|
+
}
|
|
10547
|
+
if (!model && ["local", "usb", "customer", "simulator", "emulator"].includes(provider)) {
|
|
10548
|
+
model = await detectDeviceModel(deps, { platform: f.platform, deviceId: f["device-id"]?.trim() }) ?? undefined;
|
|
10549
|
+
if (model)
|
|
10550
|
+
rt.print(`device model read from the device: ${model}`);
|
|
10551
|
+
}
|
|
10001
10552
|
if (!model)
|
|
10002
|
-
problems.push('--device-model is required: the journey history is keyed by the run set’s device (e.g. "
|
|
10553
|
+
problems.push('--device-model is required unless it can be read from a local device (--device-provider local|usb|customer|simulator|emulator, with --platform android|ios, adb or xcrun, and --device-id when more than one device is attached): the journey history is keyed by the run set’s device (e.g. "SM-S911B", "iPhone17,1")');
|
|
10003
10554
|
if (f.platform !== undefined && !["ios", "android", "web"].includes(f.platform))
|
|
10004
10555
|
problems.push("--platform must be ios, android or web");
|
|
10005
10556
|
const journeys = (f.journeys ?? "").split(",").map((j) => j.trim()).filter(Boolean);
|
|
@@ -10029,7 +10580,7 @@ async function farmOpen(deps, f, rt) {
|
|
|
10029
10580
|
}
|
|
10030
10581
|
const api = apiTarget(url, f, rt);
|
|
10031
10582
|
const now = rt.now ?? Date.now;
|
|
10032
|
-
const device = { model, os: f["device-os"] ?? null, provider
|
|
10583
|
+
const device = { model, os: f["device-os"] ?? null, provider };
|
|
10033
10584
|
const journeyInfo = await farmJourneys(deps, f, rt, journeys);
|
|
10034
10585
|
const withInstrument = { ...f, ...instrumentFlags(f) };
|
|
10035
10586
|
const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
|
|
@@ -10287,7 +10838,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
|
|
|
10287
10838
|
await deps.fs.writeFile(statePath, `${JSON.stringify(state2, null, 2)}
|
|
10288
10839
|
`);
|
|
10289
10840
|
await deps.fs.mkdir(out);
|
|
10290
|
-
await deps.fs.writeFile(
|
|
10841
|
+
await deps.fs.writeFile(join13(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: state.run_set_id, dashboard_url: summary.dashboard_url, state: state2.result.state, session: { status: session.status, message: session.message }, summary }, null, 2)}
|
|
10291
10842
|
`);
|
|
10292
10843
|
await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
|
|
10293
10844
|
const step = rt.env.GITHUB_STEP_SUMMARY;
|
|
@@ -10333,11 +10884,11 @@ ${tail.join(" ")}
|
|
|
10333
10884
|
// apps/agent/src/farm-run.ts
|
|
10334
10885
|
import { existsSync as existsSync2 } from "node:fs";
|
|
10335
10886
|
import { createRequire } from "node:module";
|
|
10336
|
-
import { dirname as dirname4, join as
|
|
10887
|
+
import { dirname as dirname4, join as join16, resolve as resolve6 } from "node:path";
|
|
10337
10888
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10338
10889
|
|
|
10339
10890
|
// apps/agent/src/app-source.ts
|
|
10340
|
-
import { join as
|
|
10891
|
+
import { join as join14 } from "node:path";
|
|
10341
10892
|
var EAS_CLI_VERSION = "24.6.0";
|
|
10342
10893
|
var isEas = (spec) => spec.startsWith("eas:");
|
|
10343
10894
|
|
|
@@ -10349,7 +10900,7 @@ class DefaultAppResolver {
|
|
|
10349
10900
|
this.env = env;
|
|
10350
10901
|
}
|
|
10351
10902
|
async resolve(spec, o) {
|
|
10352
|
-
if (spec.startsWith("bs://"))
|
|
10903
|
+
if (spec.startsWith("bs://") || spec.startsWith("custom_id:"))
|
|
10353
10904
|
return { app: spec, commit: null };
|
|
10354
10905
|
if (!isEas(spec)) {
|
|
10355
10906
|
if (!await this.deps.fs.exists(spec))
|
|
@@ -10373,7 +10924,7 @@ class DefaultAppResolver {
|
|
|
10373
10924
|
const url = build.artifacts?.buildUrl ?? build.artifacts?.applicationArchiveUrl;
|
|
10374
10925
|
if (!url)
|
|
10375
10926
|
throw new Error(`EAS build ${id} has no downloadable artifact (is it finished?)`);
|
|
10376
|
-
const file =
|
|
10927
|
+
const file = join14(o.outDir, `app-${id.slice(0, 8)}${build.platform === "ANDROID" ? ".apk" : ".ipa"}`);
|
|
10377
10928
|
await this.deps.fs.mkdir(o.outDir);
|
|
10378
10929
|
const dl = await this.deps.spawner.run("curl", ["-fsSL", "-o", file, url]);
|
|
10379
10930
|
if (dl.code !== 0)
|
|
@@ -10384,7 +10935,7 @@ class DefaultAppResolver {
|
|
|
10384
10935
|
|
|
10385
10936
|
// apps/agent/src/browserstack.ts
|
|
10386
10937
|
import { randomUUID } from "node:crypto";
|
|
10387
|
-
import { basename as basename2, join as
|
|
10938
|
+
import { basename as basename2, join as join15 } from "node:path";
|
|
10388
10939
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
10389
10940
|
var BASE = "https://api-cloud.browserstack.com/app-automate/maestro/v2";
|
|
10390
10941
|
var TERMINAL = new Set(["passed", "failed", "error", "timedout", "skipped", "stopped"]);
|
|
@@ -10455,7 +11006,7 @@ class BrowserStack {
|
|
|
10455
11006
|
}
|
|
10456
11007
|
}
|
|
10457
11008
|
async uploadApp(app, customId) {
|
|
10458
|
-
if (app.startsWith("bs://"))
|
|
11009
|
+
if (app.startsWith("bs://") || app.startsWith("custom_id:"))
|
|
10459
11010
|
return app;
|
|
10460
11011
|
const form = new FormData;
|
|
10461
11012
|
form.append("file", new Blob([await this.bytes(app)]), basename2(app));
|
|
@@ -10468,7 +11019,7 @@ class BrowserStack {
|
|
|
10468
11019
|
}
|
|
10469
11020
|
async runMaestro(b) {
|
|
10470
11021
|
const appUrl = await this.uploadApp(b.app, b.customId);
|
|
10471
|
-
const work =
|
|
11022
|
+
const work = join15(this.o.tmpDir ?? "/tmp", `keepsnappy-suite-${(this.o.uniqueId ?? randomUUID)()}`);
|
|
10472
11023
|
let suiteUrl;
|
|
10473
11024
|
try {
|
|
10474
11025
|
const zip = await this.zipSuite(b, work);
|
|
@@ -10501,17 +11052,17 @@ class BrowserStack {
|
|
|
10501
11052
|
return this.poll(buildId, b);
|
|
10502
11053
|
}
|
|
10503
11054
|
async zipSuite(b, work) {
|
|
10504
|
-
const zipPath =
|
|
10505
|
-
const stage =
|
|
11055
|
+
const zipPath = join15(work, "suite.zip");
|
|
11056
|
+
const stage = join15(work, "stage");
|
|
10506
11057
|
await this.deps.fs.mkdir(stage);
|
|
10507
11058
|
const cp = await this.deps.spawner.run("cp", ["-R", b.suiteDir.replace(/\/$/, ""), stage]);
|
|
10508
11059
|
if (cp.code !== 0)
|
|
10509
11060
|
throw new FarmApiError(`cannot copy the suite ${b.suiteDir}: ${cp.stderr || cp.error}`);
|
|
10510
11061
|
const folder = basename2(b.suiteDir.replace(/\/$/, ""));
|
|
10511
|
-
if (!await this.deps.fs.exists(
|
|
11062
|
+
if (!await this.deps.fs.exists(join15(stage, folder, "config.yaml")))
|
|
10512
11063
|
throw new FarmApiError(`${b.suiteDir} has no config.yaml: is that the Maestro workspace?`);
|
|
10513
|
-
const target =
|
|
10514
|
-
await this.deps.fs.mkdir(
|
|
11064
|
+
const target = join15(stage, folder, b.tokensPath);
|
|
11065
|
+
await this.deps.fs.mkdir(join15(target, ".."));
|
|
10515
11066
|
await this.deps.fs.writeFile(target, await this.deps.fs.readFile(b.tokensJs));
|
|
10516
11067
|
const include = SUITE_EXTENSIONS.flatMap((e) => ["-i", `${folder}/*${e}`, "-i", `${folder}/**/*${e}`]);
|
|
10517
11068
|
const z = await this.deps.spawner.run("sh", ["-c", 'cd "$1" && shift && zip -qr "$@"', "sh", stage, zipPath, folder, ...include, "-x", `${folder}/runs/*`, "-x", "*/node_modules/*"]);
|
|
@@ -10570,9 +11121,9 @@ class BrowserStackLocal {
|
|
|
10570
11121
|
const url = TUNNEL_ZIP[this.o.platform ?? process.platform];
|
|
10571
11122
|
if (!url)
|
|
10572
11123
|
throw new FarmApiError(`no BrowserStackLocal binary known for ${this.o.platform ?? process.platform}`);
|
|
10573
|
-
const dir =
|
|
10574
|
-
const bin =
|
|
10575
|
-
const zip =
|
|
11124
|
+
const dir = join15(this.o.dir, identifier);
|
|
11125
|
+
const bin = join15(dir, "BrowserStackLocal");
|
|
11126
|
+
const zip = join15(dir, "bs-local.zip");
|
|
10576
11127
|
await this.deps.fs.mkdir(dir);
|
|
10577
11128
|
for (const [cmd, args] of [
|
|
10578
11129
|
["curl", ["-fsSL", "-o", zip, url]],
|
|
@@ -10606,12 +11157,12 @@ ${r.stderr}`;
|
|
|
10606
11157
|
// apps/agent/src/farm-run.ts
|
|
10607
11158
|
function appiumDriverEntry(o = {}) {
|
|
10608
11159
|
const exists = o.exists ?? existsSync2;
|
|
10609
|
-
const bundled =
|
|
11160
|
+
const bundled = join16(o.selfDir ?? dirname4(fileURLToPath2(import.meta.url)), "appium-driver.js");
|
|
10610
11161
|
if (exists(bundled))
|
|
10611
11162
|
return bundled;
|
|
10612
11163
|
const pkgDir = dirname4(o.packageJson ? o.packageJson() : createRequire(import.meta.url).resolve("@keepsnappy/appium-driver/package.json"));
|
|
10613
|
-
const built =
|
|
10614
|
-
return exists(built) ? built :
|
|
11164
|
+
const built = join16(pkgDir, "dist", "src", "cli.js");
|
|
11165
|
+
return exists(built) ? built : join16(pkgDir, "src", "cli.ts");
|
|
10615
11166
|
}
|
|
10616
11167
|
var driverRuntime = (entry) => entry.endsWith(".ts") ? process.execPath : "node";
|
|
10617
11168
|
function nodeFarmRunPorts(deps, rt) {
|
|
@@ -10620,7 +11171,7 @@ function nodeFarmRunPorts(deps, rt) {
|
|
|
10620
11171
|
if (!user || !key)
|
|
10621
11172
|
throw new ConfigError(["BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY must be set"]);
|
|
10622
11173
|
const tmp = rt.env.RUNNER_TEMP ?? rt.env.TMPDIR ?? "/tmp";
|
|
10623
|
-
const dir =
|
|
11174
|
+
const dir = join16(tmp, "keepsnappy-bs-local");
|
|
10624
11175
|
return {
|
|
10625
11176
|
apps: new DefaultAppResolver(deps, rt.env),
|
|
10626
11177
|
provider: new BrowserStack(deps, { credentials: { user, key }, tmpDir: tmp, ...rt.fetch ? { fetch: rt.fetch } : {}, ...rt.sleep ? { sleep: rt.sleep } : {}, ...rt.now ? { now: rt.now } : {} }),
|
|
@@ -10636,7 +11187,7 @@ var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
|
10636
11187
|
function planFarmRun(f) {
|
|
10637
11188
|
const problems = [];
|
|
10638
11189
|
if (!f.app)
|
|
10639
|
-
problems.push("--app <eas:<build id>|path to .ipa/.apk|bs://id> is required");
|
|
11190
|
+
problems.push("--app <eas:<build id>|path to .ipa/.apk|bs://id|custom_id:id> is required");
|
|
10640
11191
|
const platform = f.platform ?? "ios";
|
|
10641
11192
|
if (platform !== "ios" && platform !== "android")
|
|
10642
11193
|
problems.push("--platform must be ios or android");
|
|
@@ -10682,14 +11233,15 @@ function planFarmRun(f) {
|
|
|
10682
11233
|
problems.push("--driver-script and --journey-module are two ways to run Appium; pass one");
|
|
10683
11234
|
} else if (!f["driver-script"] && !f["journey-module"]) {
|
|
10684
11235
|
problems.push("--driver appium needs --journey-module <file> (the built-in driver) or --driver-script <file> (your own driver)");
|
|
10685
|
-
} else if (f["journey-module"] && platform !== "ios") {
|
|
10686
|
-
problems.push("--journey-module: the built-in Appium driver drives iOS only");
|
|
10687
11236
|
}
|
|
10688
11237
|
if (f["run-id"] !== undefined && !RUN_ID.test(f["run-id"]))
|
|
10689
11238
|
problems.push("--run-id: letters, digits, . _ - only (it names the tunnel and its folder)");
|
|
10690
11239
|
if (f["fail-on-worse"] && !f.wait)
|
|
10691
11240
|
problems.push("--fail-on-worse needs --wait");
|
|
10692
11241
|
const timeoutMinutes = Number(f["timeout-minutes"] ?? 80);
|
|
11242
|
+
const killAfterSeconds = Number(f["kill-after-seconds"] ?? 120);
|
|
11243
|
+
if (!Number.isFinite(killAfterSeconds) || killAfterSeconds <= 0)
|
|
11244
|
+
problems.push("--kill-after-seconds must be finite and > 0");
|
|
10693
11245
|
const pollSeconds = Number(f["poll-seconds"] ?? 20);
|
|
10694
11246
|
if (!(timeoutMinutes > 0))
|
|
10695
11247
|
problems.push("--timeout-minutes must be > 0");
|
|
@@ -10709,6 +11261,7 @@ function planFarmRun(f) {
|
|
|
10709
11261
|
driverArgs: f["driver-arg"] ?? [],
|
|
10710
11262
|
out: resolve6(f.out ?? "farm-run"),
|
|
10711
11263
|
timeoutMinutes,
|
|
11264
|
+
killAfterSeconds,
|
|
10712
11265
|
pollSeconds
|
|
10713
11266
|
};
|
|
10714
11267
|
}
|
|
@@ -10732,8 +11285,8 @@ function printPlan(p, f, rt) {
|
|
|
10732
11285
|
const line = (l) => rt.print(redact(l, secrets));
|
|
10733
11286
|
line("keepsnappy farm run: dry run, no request is made and nothing is written");
|
|
10734
11287
|
line(` 1. validate: ${p.journeys.join(", ")} x ${p.repeat} on ${p.device} (${p.platform}), driver ${p.driver}`);
|
|
10735
|
-
line(` 2. resolve the app: ${p.app.startsWith("eas:") ? `eas-cli build:view ${p.app.slice(4)}, download into ${p.out}` : p.app.startsWith("bs://")
|
|
10736
|
-
line(` 3. farm open -> ${
|
|
11288
|
+
line(` 2. resolve the app: ${p.app.startsWith("eas:") ? `eas-cli build:view ${p.app.slice(4)}, download into ${p.out}` : p.app.startsWith("bs://") || p.app.startsWith("custom_id:") ? `${p.app} passes through` : `${p.app} (local file)`}`);
|
|
11289
|
+
line(` 3. farm open -> ${join16(p.out, "keepsnappy-tokens.js")}, ${join16(p.out, "keepsnappy-farm.json")} (POST /v1/run-sets, then POST /v1/runs x ${p.journeys.length * p.repeat})`);
|
|
10737
11290
|
line(` 4. tunnel: ${p.ports.length ? `BrowserStackLocal for bs-local.com ports ${p.ports.join(", ")}, identifier = run id` : "none (no --tunnel-for)"}`);
|
|
10738
11291
|
if (p.driver === "maestro") {
|
|
10739
11292
|
line(` 5. maestro on BrowserStack: upload app, zip ${f["suite-dir"]} with the tokens at ${f["tokens-path"] ?? "flows/keepsnappy/tokens.js"}, upload suite, POST build (execute ${f.execute}), poll`);
|
|
@@ -10742,7 +11295,7 @@ function printPlan(p, f, rt) {
|
|
|
10742
11295
|
}
|
|
10743
11296
|
for (const [k, v] of p.env)
|
|
10744
11297
|
line(` env ${k}=${substitute(v, {})}`);
|
|
10745
|
-
line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${
|
|
11298
|
+
line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
|
|
10746
11299
|
line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
|
|
10747
11300
|
line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse");
|
|
10748
11301
|
}
|
|
@@ -10766,13 +11319,13 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
|
|
|
10766
11319
|
timeoutMinutes: p.timeoutMinutes,
|
|
10767
11320
|
pollSeconds: p.pollSeconds,
|
|
10768
11321
|
onStarted: async (buildId) => {
|
|
10769
|
-
await deps.fs.writeFile(
|
|
11322
|
+
await deps.fs.writeFile(join16(ctx.out, `browserstack.${p.platform}.json`), `${JSON.stringify({ buildId, dashboard: `https://app-automate.browserstack.com/dashboard/v2/builds/${buildId}`, note: "written at submit; replaced by the full detail once the build ends", status: "unknown" }, null, 2)}
|
|
10770
11323
|
`);
|
|
10771
11324
|
rt.print(`build ${buildId} started: https://app-automate.browserstack.com/dashboard/v2/builds/${buildId}`);
|
|
10772
11325
|
},
|
|
10773
11326
|
onStatus: (buildId, status) => rt.print(`build ${buildId}: ${status}`)
|
|
10774
11327
|
});
|
|
10775
|
-
await deps.fs.writeFile(
|
|
11328
|
+
await deps.fs.writeFile(join16(ctx.out, `browserstack.${p.platform}.json`), `${redact(JSON.stringify(detail.detail, null, 2), secretsOf(p, f, rt.env))}
|
|
10776
11329
|
`);
|
|
10777
11330
|
return detail.status === "passed" ? { ok: true, message: null } : { ok: false, message: `the device build ${detail.buildId} ended ${detail.status}` };
|
|
10778
11331
|
}
|
|
@@ -10809,6 +11362,7 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
|
|
|
10809
11362
|
const r = await deps.spawner.run(f["driver-script"] ? "node" : driverRuntime(entry), args, {
|
|
10810
11363
|
env,
|
|
10811
11364
|
timeoutMs: p.timeoutMinutes * 60000,
|
|
11365
|
+
killAfterMs: p.killAfterSeconds * 1000,
|
|
10812
11366
|
onOutput: (chunk) => {
|
|
10813
11367
|
streamed = true;
|
|
10814
11368
|
const lines = (pending + chunk).split(`
|
|
@@ -10823,11 +11377,11 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
|
|
|
10823
11377
|
for (const l of `${r.stdout}${r.stderr}`.split(`
|
|
10824
11378
|
`))
|
|
10825
11379
|
emit(l);
|
|
10826
|
-
return r.code === 0 ? { ok: true, message: null } : { ok: false, message: `the driver script exited ${r.
|
|
11380
|
+
return r.code === 0 && !r.error ? { ok: true, message: null } : { ok: false, message: `the driver script exited ${r.error ?? r.code ?? "abnormally"}` };
|
|
10827
11381
|
}
|
|
10828
11382
|
async function farmRun(deps, f, rt, ports) {
|
|
10829
11383
|
const p = planFarmRun(f);
|
|
10830
|
-
const statePath =
|
|
11384
|
+
const statePath = join16(p.out, "keepsnappy-farm.json");
|
|
10831
11385
|
if (await deps.fs.exists(statePath))
|
|
10832
11386
|
throw new ConfigError([`${statePath} exists: a run set was opened here before; use another --out or remove ${p.out} (close it first with keepsnappy farm close)`]);
|
|
10833
11387
|
if (f["dry-run"]) {
|
|
@@ -10835,7 +11389,7 @@ async function farmRun(deps, f, rt, ports) {
|
|
|
10835
11389
|
return EXIT.ok;
|
|
10836
11390
|
}
|
|
10837
11391
|
const farm = ports ?? nodeFarmRunPorts(deps, rt);
|
|
10838
|
-
const tokensJs =
|
|
11392
|
+
const tokensJs = join16(p.out, "keepsnappy-tokens.js");
|
|
10839
11393
|
await deps.fs.mkdir(p.out);
|
|
10840
11394
|
const resolved = await farm.apps.resolve(p.app, { outDir: p.out });
|
|
10841
11395
|
const commit = f.commit ?? resolved.commit ?? undefined;
|
|
@@ -10866,9 +11420,9 @@ async function farmRun(deps, f, rt, ports) {
|
|
|
10866
11420
|
const runId = f["run-id"] ?? `keepsnappy-${p.platform}-${state.run_set_id.slice(0, 8)}`;
|
|
10867
11421
|
const ctx = { runId, out: p.out, tokensJs, runSetId: state.run_set_id, app: resolved.app };
|
|
10868
11422
|
try {
|
|
10869
|
-
await deps.spawner.run("rm", ["-f",
|
|
11423
|
+
await deps.spawner.run("rm", ["-f", join16(p.out, `browserstack.${p.platform}.json`)]);
|
|
10870
11424
|
if (p.ports.length) {
|
|
10871
|
-
tunnel = await farm.tunnel.open({ identifier: runId, ports: p.ports, logFile:
|
|
11425
|
+
tunnel = await farm.tunnel.open({ identifier: runId, ports: p.ports, logFile: join16(p.out, "browserstack-local.log") });
|
|
10872
11426
|
rt.print(`tunnel ${runId}: bs-local.com ports ${p.ports.join(", ")}`);
|
|
10873
11427
|
}
|
|
10874
11428
|
driver = await runDriver2(deps, rt, f, p, farm, { ...ctx, tunnelId: tunnel?.identifier ?? null });
|
|
@@ -10879,7 +11433,7 @@ async function farmRun(deps, f, rt, ports) {
|
|
|
10879
11433
|
rt.print(`device run failed: ${driver.message}; closing the run set with what arrived`);
|
|
10880
11434
|
closeCode = await farmClose(deps, {
|
|
10881
11435
|
state: statePath,
|
|
10882
|
-
"bs-build":
|
|
11436
|
+
"bs-build": join16(p.out, `browserstack.${p.platform}.json`),
|
|
10883
11437
|
out: p.out,
|
|
10884
11438
|
...f.wait ? { wait: true } : {},
|
|
10885
11439
|
...f["fail-on-worse"] ? { "fail-on-worse": true } : {},
|
|
@@ -10900,13 +11454,82 @@ async function farmRun(deps, f, rt, ports) {
|
|
|
10900
11454
|
}
|
|
10901
11455
|
|
|
10902
11456
|
// apps/agent/src/sourcemaps.ts
|
|
10903
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
10904
|
-
import { readFile as readFile4 } from "node:fs/promises";
|
|
10905
|
-
import { basename as basename3 } from "node:path";
|
|
11457
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
11458
|
+
import { access, readFile as readFile4, readdir as readdir2, rename, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
11459
|
+
import { basename as basename3, join as join17, resolve as resolve7 } from "node:path";
|
|
10906
11460
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
10907
11461
|
import { gzipSync } from "node:zlib";
|
|
10908
11462
|
var MAX_COMPLETE_ROUNDS = 20;
|
|
10909
11463
|
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11464
|
+
async function prepareComponents(projectRoot, options = {}) {
|
|
11465
|
+
const root = resolve7(projectRoot);
|
|
11466
|
+
const directory = join17(root, ".keepsnappy", "components");
|
|
11467
|
+
const configurations = await readdir2(directory, { withFileTypes: true }).catch((error) => {
|
|
11468
|
+
if (error.code === "ENOENT")
|
|
11469
|
+
return [];
|
|
11470
|
+
throw error;
|
|
11471
|
+
});
|
|
11472
|
+
const candidates = [];
|
|
11473
|
+
for (const entry of configurations) {
|
|
11474
|
+
if (!entry.isDirectory() || !/^[a-zA-Z0-9-]+$/.test(entry.name))
|
|
11475
|
+
continue;
|
|
11476
|
+
try {
|
|
11477
|
+
await access(join17(directory, entry.name, ".config.json"));
|
|
11478
|
+
candidates.push(entry.name);
|
|
11479
|
+
} catch (error) {
|
|
11480
|
+
if (error.code !== "ENOENT")
|
|
11481
|
+
throw error;
|
|
11482
|
+
}
|
|
11483
|
+
}
|
|
11484
|
+
candidates.sort();
|
|
11485
|
+
if (candidates.length === 0)
|
|
11486
|
+
return null;
|
|
11487
|
+
const selectedConfiguration = options.configuration ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
11488
|
+
if (!selectedConfiguration || !candidates.includes(selectedConfiguration)) {
|
|
11489
|
+
throw new ConfigError([`choose a component configuration with --components-config (${candidates.join(", ")})`]);
|
|
11490
|
+
}
|
|
11491
|
+
const selected = join17(directory, selectedConfiguration);
|
|
11492
|
+
const config = JSON.parse(await readFile4(join17(selected, ".config.json"), "utf8"));
|
|
11493
|
+
if (!isComponentConfiguration(config) || config.directory !== selectedConfiguration) {
|
|
11494
|
+
throw new ConfigError(["invalid KeepSnappy component fragment configuration"]);
|
|
11495
|
+
}
|
|
11496
|
+
const files = (await readdir2(selected)).filter((file) => file.endsWith(".json") && !file.startsWith(".")).sort();
|
|
11497
|
+
const components = [];
|
|
11498
|
+
const hashes = new Map;
|
|
11499
|
+
let matched = 0;
|
|
11500
|
+
for (const file of files) {
|
|
11501
|
+
const fragment = JSON.parse(await readFile4(join17(selected, file), "utf8"));
|
|
11502
|
+
if (!isComponentFragment(fragment))
|
|
11503
|
+
throw new ConfigError([`invalid KeepSnappy component fragment ${file}`]);
|
|
11504
|
+
if (!hashes.has(fragment.source)) {
|
|
11505
|
+
try {
|
|
11506
|
+
hashes.set(fragment.source, createHash2("sha256").update(await readFile4(resolve7(root, fragment.source))).digest("hex"));
|
|
11507
|
+
} catch (error) {
|
|
11508
|
+
if (error.code !== "ENOENT")
|
|
11509
|
+
throw error;
|
|
11510
|
+
hashes.set(fragment.source, null);
|
|
11511
|
+
}
|
|
11512
|
+
}
|
|
11513
|
+
if (hashes.get(fragment.source) !== fragment.hash)
|
|
11514
|
+
continue;
|
|
11515
|
+
matched++;
|
|
11516
|
+
components.push(...fragment.components);
|
|
11517
|
+
}
|
|
11518
|
+
if (files.length > 0 && matched === 0)
|
|
11519
|
+
options.warn?.("component fragments exist but none match the current sources");
|
|
11520
|
+
components.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.line - b.line);
|
|
11521
|
+
const file = join17(directory, "..", "components.json");
|
|
11522
|
+
const text = JSON.stringify(components);
|
|
11523
|
+
const temporary = `${file}.${process.pid}.${randomUUID2()}.tmp`;
|
|
11524
|
+
try {
|
|
11525
|
+
await writeFile2(temporary, text);
|
|
11526
|
+
await rename(temporary, file);
|
|
11527
|
+
} finally {
|
|
11528
|
+
await rm(temporary, { force: true });
|
|
11529
|
+
}
|
|
11530
|
+
const gzip = gzipSync(text);
|
|
11531
|
+
return { file, count: components.length, bytes: Buffer.byteLength(text), gzip, sha256: createHash2("sha256").update(gzip).digest("hex") };
|
|
11532
|
+
}
|
|
10910
11533
|
function stripSourcesContent(map) {
|
|
10911
11534
|
if (!isObject(map))
|
|
10912
11535
|
throw new ConfigError(["not a source map: the file is not a JSON object"]);
|
|
@@ -10982,21 +11605,35 @@ async function uploadSourcemaps(opts) {
|
|
|
10982
11605
|
}
|
|
10983
11606
|
return out;
|
|
10984
11607
|
}
|
|
10985
|
-
var SOURCEMAPS_USAGE =
|
|
11608
|
+
var SOURCEMAPS_USAGE = `keepsnappy sourcemaps upload --build <full commit sha|build id> --platform ios|android [--kind hermes|packager] [--flavour NAME] [--api URL] [--key-env KEEPSNAPPY_KEY] [--project-root PATH] [--components-config ID] <map files...>
|
|
11609
|
+
keepsnappy sourcemaps upload --eas (e2e EAS build: infer build, platform, map and kind; skips missing inputs and never fails the build)`;
|
|
10986
11610
|
async function sourcemapsCommand(args, rt) {
|
|
11611
|
+
try {
|
|
11612
|
+
return await uploadCommand(args, rt);
|
|
11613
|
+
} catch (error) {
|
|
11614
|
+
if (!args.includes("--eas"))
|
|
11615
|
+
throw error;
|
|
11616
|
+
rt.print("keepsnappy: source map upload failed (build not affected)");
|
|
11617
|
+
return 0;
|
|
11618
|
+
}
|
|
11619
|
+
}
|
|
11620
|
+
async function uploadCommand(args, rt) {
|
|
10987
11621
|
let parsed;
|
|
10988
11622
|
try {
|
|
10989
11623
|
parsed = parseArgs2({
|
|
10990
11624
|
args: [...args],
|
|
10991
11625
|
allowPositionals: true,
|
|
10992
11626
|
options: {
|
|
11627
|
+
eas: { type: "boolean" },
|
|
10993
11628
|
build: { type: "string" },
|
|
10994
11629
|
platform: { type: "string" },
|
|
10995
11630
|
kind: { type: "string" },
|
|
10996
11631
|
flavour: { type: "string" },
|
|
10997
11632
|
api: { type: "string" },
|
|
10998
11633
|
"ingest-url": { type: "string" },
|
|
10999
|
-
"key-env": { type: "string" }
|
|
11634
|
+
"key-env": { type: "string" },
|
|
11635
|
+
"project-root": { type: "string" },
|
|
11636
|
+
"components-config": { type: "string" }
|
|
11000
11637
|
}
|
|
11001
11638
|
});
|
|
11002
11639
|
} catch (e) {
|
|
@@ -11006,6 +11643,40 @@ async function sourcemapsCommand(args, rt) {
|
|
|
11006
11643
|
const [sub, ...files] = positionals;
|
|
11007
11644
|
if (sub !== "upload")
|
|
11008
11645
|
throw new ConfigError([SOURCEMAPS_USAGE]);
|
|
11646
|
+
const keyEnv = f["key-env"] ?? "KEEPSNAPPY_KEY";
|
|
11647
|
+
const key = projectKey(rt.env, keyEnv) || (f.eas && !f["key-env"] ? rt.env.KEEPSNAPPY_CI_KEY : undefined);
|
|
11648
|
+
const url = f.api ?? f["ingest-url"] ?? envValue(rt.env, "API") ?? (f.eas ? rt.env.EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL ?? "" : DEFAULT_API_URL);
|
|
11649
|
+
let preparedFiles;
|
|
11650
|
+
if (f.eas) {
|
|
11651
|
+
const skip = (reason) => {
|
|
11652
|
+
rt.print(`keepsnappy: no source map upload (${reason})`);
|
|
11653
|
+
return 0;
|
|
11654
|
+
};
|
|
11655
|
+
if (rt.env.EAS_BUILD_PROFILE !== "e2e")
|
|
11656
|
+
return skip("profile is not e2e");
|
|
11657
|
+
f.platform ??= rt.env.EAS_BUILD_PLATFORM;
|
|
11658
|
+
if (f.platform !== "ios" && f.platform !== "android")
|
|
11659
|
+
return skip("platform must be ios or android");
|
|
11660
|
+
f.build ??= rt.env.EAS_BUILD_GIT_COMMIT_HASH;
|
|
11661
|
+
if (!key)
|
|
11662
|
+
return skip("key missing");
|
|
11663
|
+
if (!url)
|
|
11664
|
+
return skip("API URL missing");
|
|
11665
|
+
if (!f.build)
|
|
11666
|
+
return skip("commit missing");
|
|
11667
|
+
f.kind ??= f.platform === "ios" ? "packager" : "hermes";
|
|
11668
|
+
f.flavour ??= "e2e";
|
|
11669
|
+
if (files.length === 0)
|
|
11670
|
+
files.push(f.platform === "ios" ? "main.jsbundle.packager.map" : "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map");
|
|
11671
|
+
preparedFiles = new Map;
|
|
11672
|
+
try {
|
|
11673
|
+
const read = rt.readFile ?? ((path) => readFile4(path, "utf8"));
|
|
11674
|
+
for (const file of files)
|
|
11675
|
+
preparedFiles.set(file, await read(file));
|
|
11676
|
+
} catch {
|
|
11677
|
+
return skip("map missing or unreadable");
|
|
11678
|
+
}
|
|
11679
|
+
}
|
|
11009
11680
|
const problems = [];
|
|
11010
11681
|
if (!f.build)
|
|
11011
11682
|
problems.push("--build <full commit sha (git rev-parse HEAD) or build id> is required");
|
|
@@ -11015,13 +11686,10 @@ async function sourcemapsCommand(args, rt) {
|
|
|
11015
11686
|
problems.push("--kind must be hermes or packager");
|
|
11016
11687
|
if (files.length === 0)
|
|
11017
11688
|
problems.push("give at least one source map file");
|
|
11018
|
-
const keyEnv = f["key-env"] ?? "KEEPSNAPPY_KEY";
|
|
11019
|
-
const key = projectKey(rt.env, keyEnv);
|
|
11020
11689
|
if (!key)
|
|
11021
11690
|
problems.push(`the project key (run scope) is missing: set the environment variable ${keyEnv}`);
|
|
11022
11691
|
if (problems.length)
|
|
11023
11692
|
throw new ConfigError(problems);
|
|
11024
|
-
const url = f.api ?? f["ingest-url"] ?? envValue(rt.env, "API") ?? DEFAULT_API_URL;
|
|
11025
11693
|
const done = await uploadSourcemaps({
|
|
11026
11694
|
url,
|
|
11027
11695
|
key,
|
|
@@ -11031,11 +11699,18 @@ async function sourcemapsCommand(args, rt) {
|
|
|
11031
11699
|
...f.kind ? { kind: f.kind } : {},
|
|
11032
11700
|
...f.flavour ? { flavour: f.flavour } : {},
|
|
11033
11701
|
...rt.fetch ? { fetch: rt.fetch } : {},
|
|
11034
|
-
...rt.readFile ? { readFile: rt.readFile } : {}
|
|
11702
|
+
...preparedFiles ? { readFile: async (path) => preparedFiles.get(path) } : rt.readFile ? { readFile: rt.readFile } : {}
|
|
11035
11703
|
});
|
|
11036
11704
|
for (const d of done) {
|
|
11037
11705
|
rt.print(`${d.file}: uploaded to build ${d.buildId} (${d.gzBytes} bytes gzip, sourcesContent removed from ${d.strippedSources} sources)`);
|
|
11038
11706
|
}
|
|
11707
|
+
try {
|
|
11708
|
+
const components = await prepareComponents(f["project-root"] ?? process.cwd(), { configuration: f["components-config"], warn: (message) => rt.print(`warning: ${message}`) });
|
|
11709
|
+
if (components)
|
|
11710
|
+
rt.print(`${components.file}: prepared ${components.count} components; upload is not yet supported by the API`);
|
|
11711
|
+
} catch (error) {
|
|
11712
|
+
rt.print(`warning: component map preparation skipped: ${error.message}`);
|
|
11713
|
+
}
|
|
11039
11714
|
return 0;
|
|
11040
11715
|
}
|
|
11041
11716
|
|
|
@@ -11043,36 +11718,94 @@ async function sourcemapsCommand(args, rt) {
|
|
|
11043
11718
|
import { parseArgs as parseArgs3 } from "node:util";
|
|
11044
11719
|
|
|
11045
11720
|
// packages/mcp/dist/src/answer.js
|
|
11721
|
+
var SHORT_SESSIONS = 3;
|
|
11722
|
+
var SHORT_POINTS = 5;
|
|
11723
|
+
var SHORT_PLACES = 5;
|
|
11724
|
+
var SHORT_FORM_NOTES = {
|
|
11725
|
+
list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
|
|
11726
|
+
get_finding: "the why, seen and layer lines, the component lines, the memory evidence lines other than the judged measure and the prompt",
|
|
11727
|
+
session_places: "the component lines",
|
|
11728
|
+
main_sessions: `the sessions older than the newest ${SHORT_SESSIONS}, the main changes of all but the newest session and, for one run_set, the places in norm`,
|
|
11729
|
+
main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
|
|
11730
|
+
place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
|
|
11731
|
+
window_history: `the runs older than the newest ${SHORT_POINTS}`,
|
|
11732
|
+
pr_status: "the per-journey dashboard links and the heat note"
|
|
11733
|
+
};
|
|
11734
|
+
var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
|
|
11735
|
+
|
|
11736
|
+
class Rows {
|
|
11737
|
+
rows = [];
|
|
11738
|
+
add(short, ...texts) {
|
|
11739
|
+
for (const t of texts)
|
|
11740
|
+
for (const l of t.split(`
|
|
11741
|
+
`))
|
|
11742
|
+
this.rows.push({ text: l, short });
|
|
11743
|
+
return this;
|
|
11744
|
+
}
|
|
11745
|
+
both(...texts) {
|
|
11746
|
+
return this.add(true, ...texts);
|
|
11747
|
+
}
|
|
11748
|
+
full(...texts) {
|
|
11749
|
+
return this.add(false, ...texts);
|
|
11750
|
+
}
|
|
11751
|
+
render(form, left = []) {
|
|
11752
|
+
if (form !== "short")
|
|
11753
|
+
return this.rows.map((r) => r.text).join(`
|
|
11754
|
+
`);
|
|
11755
|
+
const kept = [];
|
|
11756
|
+
for (const r of this.rows) {
|
|
11757
|
+
if (!r.short)
|
|
11758
|
+
continue;
|
|
11759
|
+
if (r.text === "" && (!kept.length || kept[kept.length - 1] === ""))
|
|
11760
|
+
continue;
|
|
11761
|
+
kept.push(r.text);
|
|
11762
|
+
}
|
|
11763
|
+
if (kept[kept.length - 1] === "")
|
|
11764
|
+
kept.pop();
|
|
11765
|
+
if (this.rows.some((r) => !r.short && r.text.trim() !== ""))
|
|
11766
|
+
kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
|
|
11767
|
+
return kept.join(`
|
|
11768
|
+
`);
|
|
11769
|
+
}
|
|
11770
|
+
}
|
|
11046
11771
|
var LABEL_W = 14;
|
|
11047
11772
|
var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
|
|
11048
11773
|
var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
11774
|
+
var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
|
|
11049
11775
|
function formatFinding(f, opts = {}) {
|
|
11050
|
-
const
|
|
11051
|
-
|
|
11052
|
-
|
|
11053
|
-
|
|
11054
|
-
|
|
11055
|
-
|
|
11056
|
-
line("What to do", f.answer.what_to_do),
|
|
11057
|
-
line("Seen", f.answer.lifecycle),
|
|
11058
|
-
line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
|
|
11059
|
-
line("Id", f.id)
|
|
11060
|
-
];
|
|
11776
|
+
const r = new Rows;
|
|
11777
|
+
r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
|
|
11778
|
+
r.full(line("Why", f.answer.why));
|
|
11779
|
+
r.both(line("What to do", f.answer.what_to_do));
|
|
11780
|
+
r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
|
|
11781
|
+
r.both(line("Id", f.id));
|
|
11061
11782
|
if (f.why.components.length) {
|
|
11062
|
-
|
|
11783
|
+
r.full(line("Components", ""));
|
|
11063
11784
|
for (const c of f.why.components) {
|
|
11064
|
-
|
|
11785
|
+
r.full(` ${c.component}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms own time, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
|
|
11786
|
+
}
|
|
11787
|
+
}
|
|
11788
|
+
const memory = f.why.memory ?? [];
|
|
11789
|
+
if (memory.length) {
|
|
11790
|
+
r.both(` Memory evidence: ${f.where.on === "main" ? "earlier → latest" : "main → PR"} (values are not a verdict)`);
|
|
11791
|
+
for (const m of memory) {
|
|
11792
|
+
const value = (n) => n === null ? "not stored" : fieldValueText(m.metric, n);
|
|
11793
|
+
r.add(m.metric === f.what.metric, ` ${m.label}: ${value(m.before)} → ${value(m.after)}${m.word ? ` (${m.word})` : ""}`);
|
|
11065
11794
|
}
|
|
11066
11795
|
}
|
|
11067
11796
|
if (opts.prompt)
|
|
11068
|
-
|
|
11069
|
-
return
|
|
11070
|
-
|
|
11797
|
+
r.full("", promptText(f));
|
|
11798
|
+
return r.render(opts.form, [
|
|
11799
|
+
"the why, seen and layer lines",
|
|
11800
|
+
...f.why.components.length ? [plural(f.why.components.length, "component line")] : [],
|
|
11801
|
+
...memory.some((m) => m.metric !== f.what.metric) ? [plural(memory.filter((m) => m.metric !== f.what.metric).length, "memory evidence line")] : [],
|
|
11802
|
+
...opts.prompt ? ["the prompt"] : []
|
|
11803
|
+
]);
|
|
11071
11804
|
}
|
|
11072
11805
|
var when = (iso) => iso.slice(0, 16).replace("T", " ");
|
|
11073
11806
|
var LANES_SHOWN = 8;
|
|
11074
11807
|
var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
|
|
11075
|
-
function
|
|
11808
|
+
function measuredLines(l, r) {
|
|
11076
11809
|
const f = l.filters;
|
|
11077
11810
|
const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
|
|
11078
11811
|
const m = l.measured;
|
|
@@ -11080,21 +11813,25 @@ function measuredLine(l) {
|
|
|
11080
11813
|
if (!n) {
|
|
11081
11814
|
const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
|
|
11082
11815
|
const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
|
|
11083
|
-
|
|
11816
|
+
r.both(m.run_sets ? `${scope}: ${plural(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
|
|
11817
|
+
return [];
|
|
11084
11818
|
}
|
|
11085
11819
|
const lane = (x) => {
|
|
11086
11820
|
const runs = x.runs.after !== null && x.runs.before !== null ? x.branch === l.baseline_branch ? `, ${x.runs.after} latest runs vs ${x.runs.before} earlier` : `, ${x.runs.after} branch runs vs ${x.runs.before} on ${l.baseline_branch}` : "";
|
|
11087
|
-
return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}`;
|
|
11821
|
+
return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
|
|
11088
11822
|
};
|
|
11089
|
-
if (m.lanes.length === 1)
|
|
11090
|
-
|
|
11823
|
+
if (m.lanes.length === 1) {
|
|
11824
|
+
r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`);
|
|
11825
|
+
return [];
|
|
11826
|
+
}
|
|
11091
11827
|
const shown = m.lanes.slice(0, LANES_SHOWN);
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
|
|
11095
|
-
|
|
11096
|
-
|
|
11097
|
-
`);
|
|
11828
|
+
const unjudged = shown.filter((x) => !x.judged);
|
|
11829
|
+
r.add(unjudged.length > 0, `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
|
|
11830
|
+
for (const x of shown)
|
|
11831
|
+
r.add(!x.judged, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
|
|
11832
|
+
if (m.lanes.length > shown.length)
|
|
11833
|
+
r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
|
|
11834
|
+
return [...shown.length > unjudged.length ? [plural(shown.length - unjudged.length, "judged lane")] : [], ...m.lanes.length > shown.length ? [plural(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []];
|
|
11098
11835
|
}
|
|
11099
11836
|
function countsLine2(l) {
|
|
11100
11837
|
const c = l.counts;
|
|
@@ -11114,22 +11851,69 @@ function countsLine2(l) {
|
|
|
11114
11851
|
const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
|
|
11115
11852
|
return line.charAt(0).toUpperCase() + line.slice(1);
|
|
11116
11853
|
}
|
|
11117
|
-
function formatFindingList(l) {
|
|
11118
|
-
const
|
|
11854
|
+
function formatFindingList(l, opts = {}) {
|
|
11855
|
+
const r = new Rows;
|
|
11856
|
+
const lanesLeft = measuredLines(l, r);
|
|
11119
11857
|
if (l.measured.newest || l.total)
|
|
11120
|
-
|
|
11858
|
+
r.both(countsLine2(l));
|
|
11121
11859
|
l.findings.forEach((f, i) => {
|
|
11122
|
-
|
|
11860
|
+
r.both("", `${l.offset + i + 1}. ${f.answer.headline}`);
|
|
11861
|
+
r.add(isWorseState(f.state), ` ${f.answer.where}`);
|
|
11862
|
+
r.full(` ${f.answer.how_sure}`);
|
|
11863
|
+
r.both(` ${f.answer.lifecycle} Id ${f.id}`);
|
|
11123
11864
|
});
|
|
11124
11865
|
const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
|
|
11125
11866
|
if (l.next_offset !== null)
|
|
11126
|
-
|
|
11867
|
+
r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
|
|
11127
11868
|
else if (l.offset > 0)
|
|
11128
|
-
|
|
11869
|
+
r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
|
|
11129
11870
|
if (l.findings.length)
|
|
11130
|
-
|
|
11131
|
-
|
|
11132
|
-
|
|
11871
|
+
r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
|
|
11872
|
+
const notWorse = l.findings.filter((f) => !isWorseState(f.state)).length;
|
|
11873
|
+
return r.render(opts.form, [
|
|
11874
|
+
...l.findings.length ? [plural(l.findings.length, "how-sure line")] : [],
|
|
11875
|
+
...notWorse ? [plural(notWorse, "place line")] : [],
|
|
11876
|
+
...lanesLeft
|
|
11877
|
+
]);
|
|
11878
|
+
}
|
|
11879
|
+
var STATUS_LEAD = {
|
|
11880
|
+
ok: "ok",
|
|
11881
|
+
warns: "nothing blocks, results to look at",
|
|
11882
|
+
blocks: "blocked",
|
|
11883
|
+
"cant-tell": "not ok yet, nothing was compared",
|
|
11884
|
+
"not-judged": "not ok yet, the newest run set was not judged",
|
|
11885
|
+
"never-measured": "not ok yet, never measured"
|
|
11886
|
+
};
|
|
11887
|
+
function formatBranchStatus(s, opts = {}) {
|
|
11888
|
+
const r = new Rows;
|
|
11889
|
+
r.both(`${s.branch}: ${STATUS_LEAD[s.result]}`, s.reason);
|
|
11890
|
+
if (s.lanes.length)
|
|
11891
|
+
r.both("");
|
|
11892
|
+
for (const l of s.lanes) {
|
|
11893
|
+
const badge = l.result === "blocks" ? " (blocks)" : l.word === STATE_WORDS.worse ? ` (${NOT_BLOCKING})` : "";
|
|
11894
|
+
const sha = l.commit?.sha?.slice(0, 7);
|
|
11895
|
+
r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${l.word}${badge} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
|
|
11896
|
+
for (const f of l.worst)
|
|
11897
|
+
r.both(` ${f.id} ${f.headline}`);
|
|
11898
|
+
if (l.present_findings > l.worst.length)
|
|
11899
|
+
r.both(` … ${l.present_findings - l.worst.length} more: keepsnappy findings --branch ${s.branch}`);
|
|
11900
|
+
if (l.link)
|
|
11901
|
+
r.full(` ${l.link}`);
|
|
11902
|
+
}
|
|
11903
|
+
const links = s.lanes.filter((l) => l.link).length;
|
|
11904
|
+
const heated = s.counts.heat_runs ?? 0;
|
|
11905
|
+
const more = [
|
|
11906
|
+
[false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (information, never blocks)` : ""],
|
|
11907
|
+
[true, s.not_judged_findings ? `${plural(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""],
|
|
11908
|
+
[true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
|
|
11909
|
+
[true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
|
|
11910
|
+
];
|
|
11911
|
+
const shownMore = more.filter(([, text]) => text);
|
|
11912
|
+
if (shownMore.length)
|
|
11913
|
+
r.both("");
|
|
11914
|
+
for (const [short, text] of shownMore)
|
|
11915
|
+
r.add(short, text);
|
|
11916
|
+
return r.render(opts.form, [...links ? [plural(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
|
|
11133
11917
|
}
|
|
11134
11918
|
var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
|
|
11135
11919
|
function formatCompare(r) {
|
|
@@ -11151,19 +11935,20 @@ function formatCompare(r) {
|
|
|
11151
11935
|
return out.join(`
|
|
11152
11936
|
`);
|
|
11153
11937
|
}
|
|
11154
|
-
function formatHistory(h) {
|
|
11155
|
-
const
|
|
11938
|
+
function formatHistory(h, opts = {}) {
|
|
11939
|
+
const r = new Rows;
|
|
11940
|
+
r.both(`${h.window_name} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
|
|
11156
11941
|
const ms = h.measures.slice(0, 4);
|
|
11157
11942
|
if (h.points.length) {
|
|
11158
|
-
|
|
11159
|
-
|
|
11160
|
-
|
|
11161
|
-
}
|
|
11943
|
+
r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
|
|
11944
|
+
h.points.forEach((p, i) => {
|
|
11945
|
+
r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
|
|
11946
|
+
});
|
|
11162
11947
|
}
|
|
11163
11948
|
if (h.findings.length)
|
|
11164
|
-
|
|
11165
|
-
|
|
11166
|
-
|
|
11949
|
+
r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
|
|
11950
|
+
const older = Math.max(0, h.points.length - SHORT_POINTS);
|
|
11951
|
+
return r.render(opts.form, older ? [plural(older, "older run")] : []);
|
|
11167
11952
|
}
|
|
11168
11953
|
function formatCommits(c) {
|
|
11169
11954
|
return [
|
|
@@ -11173,6 +11958,55 @@ function formatCommits(c) {
|
|
|
11173
11958
|
].join(`
|
|
11174
11959
|
`);
|
|
11175
11960
|
}
|
|
11961
|
+
var list = (xs) => xs.join(", ");
|
|
11962
|
+
function versionText(label, counts) {
|
|
11963
|
+
const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
|
|
11964
|
+
const name = (k) => k === "unknown" ? "not recorded" : k;
|
|
11965
|
+
if (values.length === 0)
|
|
11966
|
+
return "";
|
|
11967
|
+
if (values.length === 1)
|
|
11968
|
+
return `${label} ${name(values[0])}`;
|
|
11969
|
+
return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
|
|
11970
|
+
}
|
|
11971
|
+
function formatSessionPlaces(p, opts = {}) {
|
|
11972
|
+
const s = p.session;
|
|
11973
|
+
const sha = s.commit?.sha?.slice(0, 7);
|
|
11974
|
+
const commit = s.commit ? `${s.commit.title ? `“${s.commit.title}”${s.commit.author ? ` by ${s.commit.author}` : ""}` : sha ?? "commit not recorded"}${s.commit.title && sha ? ` (${sha})` : ""}` : "commit not recorded";
|
|
11975
|
+
const v = s.versions;
|
|
11976
|
+
const codes = Object.keys(s.runs.invalid_codes);
|
|
11977
|
+
const r = new Rows().both(`Session ${s.completed_at ? `${when(s.completed_at)} ` : ""}on ${s.branch}: ${commit}${s.pull_request?.number ? `, PR #${s.pull_request.number}` : ""}; ${s.journey.title ?? s.journey.key} on ${s.phone.model}${s.phone.os ? ` · ${s.phone.os}` : ""}${s.status === "processed" ? "" : ` (run set ${s.status})`}.`, `Runs: ${s.runs.valid} valid${s.runs.invalid ? `, ${s.runs.invalid} invalid${codes.length ? ` (${list(codes)})` : ""}` : ""}. ${[
|
|
11978
|
+
versionText("SDK", v.sdk),
|
|
11979
|
+
versionText("driver", v.driver),
|
|
11980
|
+
versionText("driver version", v.driver_version),
|
|
11981
|
+
versionText("app", v.app_build),
|
|
11982
|
+
versionText("journey version", v.journey_version),
|
|
11983
|
+
`derivation ${v.derivation}`,
|
|
11984
|
+
`stats ${v.stats}`
|
|
11985
|
+
].filter(Boolean).join(", ")}.`);
|
|
11986
|
+
if (s.not_computed)
|
|
11987
|
+
return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
|
|
11988
|
+
if (s.stale)
|
|
11989
|
+
return r.both("Stale: its statistics are of another derivation or stats version and are not served until the next recompute rewrites them; nothing is known about its places (not zero).").render(opts.form);
|
|
11990
|
+
const by = p.filters.measure;
|
|
11991
|
+
const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
|
|
11992
|
+
r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`, "");
|
|
11993
|
+
let componentLines = 0;
|
|
11994
|
+
p.places.forEach((place, i) => {
|
|
11995
|
+
const m = place.measures.find((x) => x.measure === by);
|
|
11996
|
+
const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
|
|
11997
|
+
const comps = place.components.map((c) => `${c.name} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural(c.renders_median, "render")}` : ""})`);
|
|
11998
|
+
r.both(`${p.offset + i + 1}. ${place.window_name}: ${value}`);
|
|
11999
|
+
if (comps.length) {
|
|
12000
|
+
r.full(` Components: ${list(comps)}`);
|
|
12001
|
+
componentLines++;
|
|
12002
|
+
}
|
|
12003
|
+
});
|
|
12004
|
+
if (!p.places.length)
|
|
12005
|
+
r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
|
|
12006
|
+
else if (p.next_offset !== null)
|
|
12007
|
+
r.both("", `Showing ${p.offset + 1}–${p.offset + p.places.length} of ${p.total}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).`);
|
|
12008
|
+
return r.render(opts.form, componentLines ? [plural(componentLines, "component line")] : []);
|
|
12009
|
+
}
|
|
11176
12010
|
function formatLayers(l) {
|
|
11177
12011
|
return [
|
|
11178
12012
|
l.note,
|
|
@@ -11182,6 +12016,100 @@ function formatLayers(l) {
|
|
|
11182
12016
|
].join(`
|
|
11183
12017
|
`);
|
|
11184
12018
|
}
|
|
12019
|
+
var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
|
|
12020
|
+
function formatMainSessions(h, opts = {}) {
|
|
12021
|
+
const baseline = { baseline: { sessions: h.baseline_sessions } };
|
|
12022
|
+
const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
|
|
12023
|
+
if (!h.sessions.length)
|
|
12024
|
+
r.both("No main sessions measured for this journey and phone.");
|
|
12025
|
+
const recent = new Set(h.sessions.slice(-SHORT_SESSIONS));
|
|
12026
|
+
const latest = h.sessions[h.sessions.length - 1];
|
|
12027
|
+
let otherChanges = 0;
|
|
12028
|
+
for (const s of h.sessions) {
|
|
12029
|
+
const k = recent.has(s);
|
|
12030
|
+
r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
|
|
12031
|
+
if (!s.places)
|
|
12032
|
+
r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
|
|
12033
|
+
else
|
|
12034
|
+
r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : ""}`);
|
|
12035
|
+
if (s.waiting_sessions)
|
|
12036
|
+
r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
|
|
12037
|
+
if (s.memory)
|
|
12038
|
+
r.add(k, ` Memory: ${MAIN_TIER_WORDS[s.memory.tier]}, ${s.memory.median} MB (n ${s.memory.n})${s.memory.cant_reason ? `; ${mainCantWords(s.memory.cant_reason, baseline)}` : ""}`);
|
|
12039
|
+
for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
|
|
12040
|
+
r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
|
|
12041
|
+
for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
|
|
12042
|
+
r.add(s === latest, ` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
|
|
12043
|
+
if (k && s !== latest)
|
|
12044
|
+
otherChanges++;
|
|
12045
|
+
}
|
|
12046
|
+
}
|
|
12047
|
+
const older = h.sessions.length - recent.size;
|
|
12048
|
+
return r.render(opts.form, [...older ? [plural(older, "older session")] : [], ...otherChanges ? [plural(otherChanges, "main change") + " of the other recent sessions"] : []]);
|
|
12049
|
+
}
|
|
12050
|
+
function formatMainSessionPlaces(p, opts = {}) {
|
|
12051
|
+
const r = new Rows;
|
|
12052
|
+
if (p.status !== "ok")
|
|
12053
|
+
return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
|
|
12054
|
+
const baselineSessions = p.baseline_sessions;
|
|
12055
|
+
r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
|
|
12056
|
+
for (const x of p.places)
|
|
12057
|
+
r.add(x.tier !== "same", ` ${x.place} ${x.ref}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
|
|
12058
|
+
const inNorm = p.places.filter((x) => x.tier === "same").length;
|
|
12059
|
+
return r.render(opts.form, inNorm ? [plural(inNorm, "place") + " in norm"] : []);
|
|
12060
|
+
}
|
|
12061
|
+
var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
|
|
12062
|
+
var previousLine = (p) => ` ${p.place} ${p.ref}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
|
|
12063
|
+
function formatMainVsPrevious(m, opts = {}) {
|
|
12064
|
+
const r = new Rows().both(`Information only: main session ${m.run_set_id} vs ${m.previous_run_set_id ?? "no previous session"}.`, `Measure: ${m.measure}; ${m.status}.`);
|
|
12065
|
+
if (m.cut === "unknown")
|
|
12066
|
+
r.both("History restart: unknown; current summaries unavailable.");
|
|
12067
|
+
else if (m.cut)
|
|
12068
|
+
r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
|
|
12069
|
+
if (m.not_computed.length)
|
|
12070
|
+
r.both(`not_computed: ${m.not_computed.join(", ")}`);
|
|
12071
|
+
if (m.stale.length)
|
|
12072
|
+
r.both(`stale: ${m.stale.join(", ")}`);
|
|
12073
|
+
if (m.journey)
|
|
12074
|
+
r.both("Whole journey:", previousLine(m.journey));
|
|
12075
|
+
m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p)));
|
|
12076
|
+
if (m.next_offset !== null)
|
|
12077
|
+
r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
|
|
12078
|
+
const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
|
|
12079
|
+
return r.render(opts.form, smaller ? [plural(smaller, "smaller mover")] : []);
|
|
12080
|
+
}
|
|
12081
|
+
function formatPlaceTrend(t, opts = {}) {
|
|
12082
|
+
const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
|
|
12083
|
+
const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
|
|
12084
|
+
const olderSessions = new Set;
|
|
12085
|
+
let olderRestarts = 0;
|
|
12086
|
+
for (const session of t.sessions) {
|
|
12087
|
+
const k = newest.has(session.run_set_id);
|
|
12088
|
+
if (session.restarts_known === false)
|
|
12089
|
+
r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
|
|
12090
|
+
for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
|
|
12091
|
+
r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
|
|
12092
|
+
if (!k)
|
|
12093
|
+
olderRestarts++;
|
|
12094
|
+
}
|
|
12095
|
+
for (const place of t.places) {
|
|
12096
|
+
const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
|
|
12097
|
+
if (!value)
|
|
12098
|
+
continue;
|
|
12099
|
+
const unit = place.unit ? ` ${place.unit}` : "";
|
|
12100
|
+
if (!k)
|
|
12101
|
+
olderSessions.add(session.run_set_id);
|
|
12102
|
+
r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${place.window} · ${place.label} (${place.ref}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
|
|
12103
|
+
}
|
|
12104
|
+
}
|
|
12105
|
+
if (!t.sessions.length)
|
|
12106
|
+
r.both("No main sessions.");
|
|
12107
|
+
if (t.not_computed.length)
|
|
12108
|
+
r.both(`Not computed: ${t.not_computed.join(", ")}`);
|
|
12109
|
+
if (t.stale.length)
|
|
12110
|
+
r.both(`Stale: ${t.stale.join(", ")}`);
|
|
12111
|
+
return r.render(opts.form, [...olderSessions.size ? [plural(olderSessions.size, "older session")] : [], ...olderRestarts ? [plural(olderRestarts, "older restart marker")] : []]);
|
|
12112
|
+
}
|
|
11185
12113
|
|
|
11186
12114
|
// packages/mcp/dist/src/client.js
|
|
11187
12115
|
class ApiError extends Error {
|
|
@@ -11196,9 +12124,13 @@ class ApiError extends Error {
|
|
|
11196
12124
|
}
|
|
11197
12125
|
async function get(t, path, query = {}) {
|
|
11198
12126
|
const u = new URL(path, t.url.endsWith("/") ? t.url : `${t.url}/`);
|
|
11199
|
-
for (const [k, v] of Object.entries(query))
|
|
11200
|
-
if (v
|
|
12127
|
+
for (const [k, v] of Object.entries(query)) {
|
|
12128
|
+
if (Array.isArray(v))
|
|
12129
|
+
for (const value of v)
|
|
12130
|
+
u.searchParams.append(k, value);
|
|
12131
|
+
else if (v !== undefined && v !== "")
|
|
11201
12132
|
u.searchParams.set(k, String(v));
|
|
12133
|
+
}
|
|
11202
12134
|
const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
|
|
11203
12135
|
const text = await r.text();
|
|
11204
12136
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -11211,19 +12143,47 @@ async function get(t, path, query = {}) {
|
|
|
11211
12143
|
return body;
|
|
11212
12144
|
}
|
|
11213
12145
|
var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, state: f.state, limit: f.limit, offset: f.offset || undefined });
|
|
12146
|
+
var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
|
|
11214
12147
|
var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
|
|
11215
12148
|
var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
|
|
11216
12149
|
var windowHistory = (t, q) => get(t, "v1/history", q);
|
|
11217
12150
|
var commitsBetween = (t, q) => get(t, "v1/commits", q);
|
|
11218
12151
|
var layers = (t) => get(t, "v1/layers");
|
|
12152
|
+
var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
|
|
12153
|
+
run_set: f.run_set,
|
|
12154
|
+
journey: f.journey,
|
|
12155
|
+
phone: f.phone,
|
|
12156
|
+
branch: f.branch,
|
|
12157
|
+
measure: f.measure,
|
|
12158
|
+
sort: f.sort,
|
|
12159
|
+
window: f.window,
|
|
12160
|
+
limit: f.limit,
|
|
12161
|
+
offset: f.offset || undefined,
|
|
12162
|
+
top_components: f.top_components
|
|
12163
|
+
});
|
|
12164
|
+
var SESSION_SORTS = ["median", "p90", "mad"];
|
|
12165
|
+
var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
|
|
12166
|
+
var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
|
|
12167
|
+
var mainVsPrevious = (t, q) => {
|
|
12168
|
+
const { run_set, ...query } = q;
|
|
12169
|
+
return get(t, `v1/main/sessions/${encodeURIComponent(run_set ?? "latest")}/vs-previous`, query);
|
|
12170
|
+
};
|
|
12171
|
+
var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
|
|
11219
12172
|
|
|
11220
12173
|
// apps/agent/src/findings.ts
|
|
11221
|
-
var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--json]
|
|
11222
|
-
|
|
11223
|
-
|
|
11224
|
-
|
|
11225
|
-
|
|
12174
|
+
var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--short] [--json]
|
|
12175
|
+
status --branch NAME [--short] [--json] (is my PR ok? one answer: blocked / nothing blocks / ok, per journey × phone the word, the ids of the worst findings, dashboard links; never measured and not judged are not ok)
|
|
12176
|
+
finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
|
|
12177
|
+
compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
|
|
12178
|
+
history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
|
|
12179
|
+
commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
|
|
12180
|
+
places [--run-set ID | --journey KEY [--phone MODEL] [--branch NAME]] [--measure ID] [--sort median|p90|mad] [--window TEXT] [--limit N] [--offset N] [--top-components N] [--short] [--json]
|
|
12181
|
+
(every place of one session from its stored statistics: no verdict; the newest closed run set of the journey when --run-set is not given)
|
|
12182
|
+
main --journey KEY --phone MODEL [--sessions N | --session ID] [--vs-previous] [--measure REF] [--limit N] [--offset N] [--short] [--json]
|
|
11226
12183
|
layers [--json]
|
|
12184
|
+
trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
|
|
12185
|
+
(the words after the journey are one window name; more windows with repeated --window)
|
|
12186
|
+
--short (findings, status, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
|
|
11227
12187
|
(key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
|
|
11228
12188
|
|
|
11229
12189
|
class UsageError extends ConfigError {
|
|
@@ -11235,7 +12195,8 @@ class UsageError extends ConfigError {
|
|
|
11235
12195
|
}
|
|
11236
12196
|
}
|
|
11237
12197
|
var STATES = ["present", "not-judged", "resolved", "all"];
|
|
11238
|
-
var
|
|
12198
|
+
var SHORT_COMMANDS = new Set(["findings", "status", "finding", "history", "places", "main", "trend"]);
|
|
12199
|
+
var FINDINGS_COMMANDS = new Set(["findings", "status", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
|
|
11239
12200
|
async function findingsCommand(cmd, argv, rt) {
|
|
11240
12201
|
const args = (() => {
|
|
11241
12202
|
try {
|
|
@@ -11248,12 +12209,22 @@ async function findingsCommand(cmd, argv, rt) {
|
|
|
11248
12209
|
journey: { type: "string" },
|
|
11249
12210
|
phone: { type: "string" },
|
|
11250
12211
|
state: { type: "string" },
|
|
12212
|
+
session: { type: "string" },
|
|
12213
|
+
"vs-previous": { type: "boolean" },
|
|
11251
12214
|
limit: { type: "string" },
|
|
11252
12215
|
offset: { type: "string" },
|
|
12216
|
+
"run-set": { type: "string" },
|
|
12217
|
+
measure: { type: "string" },
|
|
12218
|
+
sort: { type: "string" },
|
|
12219
|
+
"top-components": { type: "string" },
|
|
11253
12220
|
json: { type: "boolean" },
|
|
12221
|
+
short: { type: "boolean" },
|
|
11254
12222
|
prompt: { type: "boolean" },
|
|
11255
12223
|
api: { type: "string" },
|
|
11256
|
-
"key-env": { type: "string" }
|
|
12224
|
+
"key-env": { type: "string" },
|
|
12225
|
+
window: { type: "string", multiple: true },
|
|
12226
|
+
ref: { type: "string", multiple: true },
|
|
12227
|
+
sessions: { type: "string" }
|
|
11257
12228
|
}
|
|
11258
12229
|
});
|
|
11259
12230
|
} catch (e) {
|
|
@@ -11266,8 +12237,9 @@ async function findingsCommand(cmd, argv, rt) {
|
|
|
11266
12237
|
if (!key)
|
|
11267
12238
|
throw new ConfigError([`a project key with read scope is needed: set the environment variable ${keyEnv}`]);
|
|
11268
12239
|
const t = { url: o.api ?? envValue(rt.env, "API") ?? DEFAULT_API_URL, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
|
|
12240
|
+
const answerForm = { form: o.short ? "short" : "full" };
|
|
11269
12241
|
const out = (v, text) => {
|
|
11270
|
-
rt.print(o.json ? JSON.stringify(v, null, 2) : text(v));
|
|
12242
|
+
rt.print(o.json ? JSON.stringify(v, null, 2) : text(v, answerForm));
|
|
11271
12243
|
return 0;
|
|
11272
12244
|
};
|
|
11273
12245
|
const need = (n, what) => {
|
|
@@ -11277,10 +12249,29 @@ async function findingsCommand(cmd, argv, rt) {
|
|
|
11277
12249
|
const limit = o.limit === undefined ? undefined : Number(o.limit);
|
|
11278
12250
|
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200))
|
|
11279
12251
|
throw new UsageError(["--limit must be a whole number from 1 to 200 (page with --offset)"]);
|
|
12252
|
+
if (o.short && !SHORT_COMMANDS.has(cmd))
|
|
12253
|
+
throw new UsageError([`--short is for ${[...SHORT_COMMANDS].join(", ")}; keepsnappy ${cmd} has only one form`]);
|
|
11280
12254
|
const offset = o.offset === undefined ? undefined : Number(o.offset);
|
|
11281
12255
|
if (offset !== undefined && (!Number.isInteger(offset) || offset < 0))
|
|
11282
12256
|
throw new UsageError(["--offset must be a whole number >= 0"]);
|
|
11283
12257
|
switch (cmd) {
|
|
12258
|
+
case "main": {
|
|
12259
|
+
if (!o.journey)
|
|
12260
|
+
throw new UsageError(["keepsnappy main needs --journey KEY"]);
|
|
12261
|
+
if (!o.phone)
|
|
12262
|
+
throw new UsageError(["keepsnappy main needs --phone MODEL (exact device model)"]);
|
|
12263
|
+
const sessions = o.sessions === undefined ? undefined : Number(o.sessions);
|
|
12264
|
+
if (sessions !== undefined && (!Number.isInteger(sessions) || sessions < 1 || sessions > 20))
|
|
12265
|
+
throw new UsageError(["--sessions must be a whole number from 1 to 20"]);
|
|
12266
|
+
if (sessions !== undefined && (o.session || o["vs-previous"]))
|
|
12267
|
+
throw new UsageError(["--sessions is for the session list"]);
|
|
12268
|
+
const lane = { journey: o.journey, phone: o.phone };
|
|
12269
|
+
if (o["vs-previous"])
|
|
12270
|
+
return out(await mainVsPrevious(t, { ...lane, run_set: o.session, measure: o.measure, limit, offset }), formatMainVsPrevious);
|
|
12271
|
+
if (o.session)
|
|
12272
|
+
return out(await mainSessionPlaces(t, o.session, lane), formatMainSessionPlaces);
|
|
12273
|
+
return out(await mainSessions(t, { ...lane, sessions }), formatMainSessions);
|
|
12274
|
+
}
|
|
11284
12275
|
case "findings": {
|
|
11285
12276
|
if (o.branch && o.main)
|
|
11286
12277
|
throw new UsageError(["give --branch or --main, not both"]);
|
|
@@ -11297,14 +12288,18 @@ async function findingsCommand(cmd, argv, rt) {
|
|
|
11297
12288
|
...offset ? { offset } : {}
|
|
11298
12289
|
}), formatFindingList);
|
|
11299
12290
|
}
|
|
12291
|
+
case "status":
|
|
12292
|
+
if (!o.branch)
|
|
12293
|
+
throw new UsageError(["keepsnappy status needs --branch NAME", FINDINGS_USAGE]);
|
|
12294
|
+
return out(await branchStatus(t, o.branch), formatBranchStatus);
|
|
11300
12295
|
case "finding": {
|
|
11301
12296
|
need(1, "<id>");
|
|
11302
12297
|
const f = await getFinding(t, p[0]);
|
|
11303
12298
|
if (o.prompt && !o.json) {
|
|
11304
|
-
rt.print(f
|
|
12299
|
+
rt.print(promptText(f));
|
|
11305
12300
|
return 0;
|
|
11306
12301
|
}
|
|
11307
|
-
return out(f,
|
|
12302
|
+
return out(f, formatFinding);
|
|
11308
12303
|
}
|
|
11309
12304
|
case "compare":
|
|
11310
12305
|
need(2, "<runA> <runB>");
|
|
@@ -11315,8 +12310,39 @@ async function findingsCommand(cmd, argv, rt) {
|
|
|
11315
12310
|
case "commits":
|
|
11316
12311
|
need(1, "<from>");
|
|
11317
12312
|
return out(await commitsBetween(t, { from: p[0], ...p[1] ? { to: p[1] } : {}, ...o.branch ? { branch: o.branch } : {} }), formatCommits);
|
|
12313
|
+
case "places": {
|
|
12314
|
+
const sort = o.sort;
|
|
12315
|
+
if (sort && !SESSION_SORTS.includes(sort))
|
|
12316
|
+
throw new UsageError([`--sort is ${SESSION_SORTS.join(", ")}`]);
|
|
12317
|
+
const top = o["top-components"] === undefined ? undefined : Number(o["top-components"]);
|
|
12318
|
+
if (top !== undefined && (!Number.isInteger(top) || top < 0 || top > 20))
|
|
12319
|
+
throw new UsageError(["--top-components must be a whole number from 0 to 20"]);
|
|
12320
|
+
return out(await sessionPlaces(t, {
|
|
12321
|
+
...o["run-set"] ? { run_set: o["run-set"] } : {},
|
|
12322
|
+
...o.journey ? { journey: o.journey } : {},
|
|
12323
|
+
...o.phone ? { phone: o.phone } : {},
|
|
12324
|
+
...o.branch ? { branch: o.branch } : {},
|
|
12325
|
+
...o.measure ? { measure: o.measure } : {},
|
|
12326
|
+
...sort ? { sort } : {},
|
|
12327
|
+
...o.window?.length ? { window: o.window[0] } : {},
|
|
12328
|
+
...limit ? { limit } : {},
|
|
12329
|
+
...offset ? { offset } : {},
|
|
12330
|
+
...top !== undefined ? { top_components: top } : {}
|
|
12331
|
+
}), formatSessionPlaces);
|
|
12332
|
+
}
|
|
11318
12333
|
case "layers":
|
|
11319
12334
|
return out(await layers(t), formatLayers);
|
|
12335
|
+
case "trend": {
|
|
12336
|
+
const journey = o.journey ?? p[0];
|
|
12337
|
+
const words = p.slice(o.journey ? 0 : 1).join(" ");
|
|
12338
|
+
const windows = [...words ? [words] : [], ...o.window ?? []];
|
|
12339
|
+
if (!journey || !windows.length || !o.phone)
|
|
12340
|
+
throw new UsageError(["keepsnappy trend needs <journey> <window> --phone MODEL", FINDINGS_USAGE]);
|
|
12341
|
+
const sessions = o.sessions === undefined ? undefined : Number(o.sessions);
|
|
12342
|
+
if (sessions !== undefined && (!Number.isInteger(sessions) || sessions < 1 || sessions > 30))
|
|
12343
|
+
throw new UsageError(["--sessions must be a whole number from 1 to 30"]);
|
|
12344
|
+
return out(await placeTrend(t, { journey, phone: o.phone, window: windows, ref: o.ref, sessions }), formatPlaceTrend);
|
|
12345
|
+
}
|
|
11320
12346
|
default:
|
|
11321
12347
|
throw new UsageError([FINDINGS_USAGE]);
|
|
11322
12348
|
}
|
|
@@ -11332,17 +12358,19 @@ var USAGE = `keepsnappy <command>
|
|
|
11332
12358
|
[--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
|
|
11333
12359
|
[--clock-offset-ms N] [--no-markers]
|
|
11334
12360
|
-- <test command...>
|
|
11335
|
-
farm open --api URL --device-model
|
|
12361
|
+
farm open --api URL [--device-model CODE] [--device-os V] [--device-id SERIAL|UDID] [--device-provider browserstack] [--platform ios|android] --journeys a,b [--repeat 5]
|
|
12362
|
+
(--device-model is read from the device with --platform and a local --device-provider: adb ro.product.model, devicectl machine id, simctl name; never for browserstack)
|
|
11336
12363
|
--tokens-js <file> --state <file> [--branch NAME --commit SHA --base-commit SHA] [--ttl-seconds 21600] [--key-env KEEPSNAPPY_KEY]
|
|
11337
12364
|
[--config keepsnappy.yml] [--flows <files|dirs|globs,...>] (journey titles: keepsnappy.yml flows.paths, else the flow's name: / top comment)
|
|
11338
12365
|
[--completion-marker] (the flow posts step run:complete at the end of each iteration; runs without it are invalid)
|
|
11339
12366
|
[--driver appium --journey-module FILE] [--driver-version V] [--app-build-id ID] [--harness-version V] [--journey-version V] [--flag-set LABEL] (setup fingerprint, plan 51)
|
|
11340
12367
|
[--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
|
|
11341
12368
|
farm close --state <file> [--wait [--fail-on-worse]] [--bs-build browserstack.ios.json] [--settle-seconds 60] [--max-wait-seconds 300] [--out DIR]
|
|
11342
|
-
farm run --app <eas:ID|file.ipa|bs://ID> --journeys a,b [--repeat 5] --device "iPhone 15 Pro-17" [--platform ios|android] [--driver maestro|appium]
|
|
12369
|
+
farm run --app <eas:ID|file.ipa|file.apk|bs://ID|custom_id:ID> --journeys a,b [--repeat 5] --device "iPhone 15 Pro-17" [--platform ios|android] [--driver maestro|appium]
|
|
11343
12370
|
[--tunnel-for localhost:PORT ...] [--env KEY=VALUE ...] (values may use {tunnel:PORT} {run_id} {out} {tokens_js} {run_set_id})
|
|
11344
12371
|
maestro: --suite-dir DIR --execute FLOW [--tokens-path flows/keepsnappy/tokens.js] [--maestro-version V]
|
|
11345
|
-
appium: --journey-module FILE | --driver-script FILE [--driver-arg ARG ...]
|
|
12372
|
+
appium: --journey-module FILE | --driver-script FILE [--driver-arg ARG ...] (iOS and Android)
|
|
12373
|
+
[--timeout-minutes 80] [--kill-after-seconds 120] (Appium: SIGTERM at timeout, then SIGKILL after grace)
|
|
11346
12374
|
[--branch NAME --commit SHA] [--wait [--fail-on-worse]] [--out DIR] [--dry-run] (open + tunnel + device + close; docs/plans/54a)
|
|
11347
12375
|
extract <out-dir> --ri-capture observations.ndjson [--mem-window NAME]
|
|
11348
12376
|
aa <out-dir> [--reps N] [--boot N]
|
|
@@ -11440,6 +12468,7 @@ async function main2(argv) {
|
|
|
11440
12468
|
"tokens-path": { type: "string" },
|
|
11441
12469
|
"maestro-version": { type: "string" },
|
|
11442
12470
|
"timeout-minutes": { type: "string" },
|
|
12471
|
+
"kill-after-seconds": { type: "string" },
|
|
11443
12472
|
"poll-seconds": { type: "string" },
|
|
11444
12473
|
"custom-id": { type: "string" },
|
|
11445
12474
|
"run-id": { type: "string" },
|
|
@@ -11473,9 +12502,9 @@ async function main2(argv) {
|
|
|
11473
12502
|
if (platform === "ios-device" && !o.device)
|
|
11474
12503
|
throw new Error("--device <udid> is required for ios-device");
|
|
11475
12504
|
const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
|
|
11476
|
-
const out =
|
|
12505
|
+
const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
11477
12506
|
const udid = o.device ?? "booted";
|
|
11478
|
-
const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid,
|
|
12507
|
+
const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve8(out, ".scratch"));
|
|
11479
12508
|
const installer = platform === "ios-sim" ? new IosSimulatorInstaller(deps, udid) : new IosDeviceInstaller(deps, udid);
|
|
11480
12509
|
const bundleId = await bundleIdOf(deps, arms[0]?.target, o["bundle-id"]);
|
|
11481
12510
|
let markers;
|
|
@@ -11494,7 +12523,7 @@ async function main2(argv) {
|
|
|
11494
12523
|
outDir: out,
|
|
11495
12524
|
testCommand: positionals,
|
|
11496
12525
|
bundleId,
|
|
11497
|
-
...o["ri-capture"] ? { ri: new FileRiSource(deps,
|
|
12526
|
+
...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve8(o["ri-capture"])) } : {},
|
|
11498
12527
|
...o["mem-window"] ? { memWindow: o["mem-window"] } : {},
|
|
11499
12528
|
...o.timeout ? { testTimeoutMs: Number(o.timeout) * 1000 } : {}
|
|
11500
12529
|
});
|
|
@@ -11506,9 +12535,9 @@ async function main2(argv) {
|
|
|
11506
12535
|
const dir = positionals[0];
|
|
11507
12536
|
if (!dir)
|
|
11508
12537
|
throw new Error("extract needs <out-dir>");
|
|
11509
|
-
const ri = o["ri-capture"] ? new FileRiSource(deps,
|
|
11510
|
-
const rows = await extract(deps,
|
|
11511
|
-
console.log(`${rows.length} rows -> ${
|
|
12538
|
+
const ri = o["ri-capture"] ? new FileRiSource(deps, resolve8(o["ri-capture"])) : undefined;
|
|
12539
|
+
const rows = await extract(deps, resolve8(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
|
|
12540
|
+
console.log(`${rows.length} rows -> ${resolve8(dir, "metrics.csv")}`);
|
|
11512
12541
|
return 0;
|
|
11513
12542
|
}
|
|
11514
12543
|
if (cmd === "aa") {
|
|
@@ -11516,7 +12545,7 @@ async function main2(argv) {
|
|
|
11516
12545
|
if (!dir)
|
|
11517
12546
|
throw new Error("aa needs <out-dir>");
|
|
11518
12547
|
const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
|
|
11519
|
-
console.log((await aa(deps,
|
|
12548
|
+
console.log((await aa(deps, resolve8(dir), { config })).table);
|
|
11520
12549
|
return 0;
|
|
11521
12550
|
}
|
|
11522
12551
|
console.error(USAGE);
|