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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/agent/src/cli.ts
4
- import { resolve as resolve7 } from "node:path";
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
@@ -212,6 +212,15 @@ class ConfigError extends Error {
212
212
  this.name = "ConfigError";
213
213
  }
214
214
  }
215
+
216
+ class UsageError extends ConfigError {
217
+ constructor(lines) {
218
+ super(lines);
219
+ this.message = lines.join(`
220
+ `);
221
+ this.name = "UsageError";
222
+ }
223
+ }
215
224
  var TITLE_MAX = 200;
216
225
  var DESCRIPTION_MAX = 2000;
217
226
  var TOP = ["project", "app", "platform", "device", "repetitions", "flows", "steps", "output", "timeout_seconds"];
@@ -1664,6 +1673,36 @@ var MEASURES = {
1664
1673
  decimals: 2,
1665
1674
  what: "The share of samples at heat level serious or above"
1666
1675
  },
1676
+ "sampler.thermal.raw_start": {
1677
+ unit: "state",
1678
+ worse: "none",
1679
+ at: ["journey"],
1680
+ producer: "derive",
1681
+ label: "Raw Android thermal status at the start (0–6)",
1682
+ short: "raw Android thermal status at the start (0–6)",
1683
+ decimals: 0,
1684
+ what: "The first raw Android thermal status reported during the journey"
1685
+ },
1686
+ "sampler.thermal.raw_max": {
1687
+ unit: "state",
1688
+ worse: "none",
1689
+ at: ["journey"],
1690
+ producer: "derive",
1691
+ label: "Raw Android thermal status (peak, 0–6)",
1692
+ short: "raw Android thermal status (peak, 0–6)",
1693
+ decimals: 0,
1694
+ what: "The highest raw Android thermal status reported during the journey"
1695
+ },
1696
+ "sampler.thermal.raw_light_share": {
1697
+ unit: "ratio",
1698
+ worse: "none",
1699
+ at: ["journey"],
1700
+ producer: "derive",
1701
+ label: "Share of Android samples at LIGHT or above",
1702
+ short: "share of Android samples at LIGHT or above",
1703
+ decimals: 2,
1704
+ what: "The share of samples with a raw Android thermal status of 1 (LIGHT) or above, among samples reporting that status"
1705
+ },
1667
1706
  "sampler.thermal.heats_up": {
1668
1707
  unit: "share",
1669
1708
  worse: "higher",
@@ -2103,6 +2142,7 @@ var DIMENSIONS = {
2103
2142
  };
2104
2143
  var BREAKDOWN_MEASURES = {
2105
2144
  self_ms: { unit: "ms", worse: "higher", label: "render time", decimals: 1 },
2145
+ rolled_up_ms: { unit: "ms", worse: "none", label: "render time from libraries", decimals: 1 },
2106
2146
  total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
2107
2147
  renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
2108
2148
  mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
@@ -2112,7 +2152,53 @@ var BREAKDOWN_MEASURES = {
2112
2152
  var measureDef = (id) => isMeasureId(id) ? MEASURES[id] : undefined;
2113
2153
  var HEAT_METRIC = "sampler.thermal.heats_up";
2114
2154
  var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
2155
+ var FULL_ROW_MS = 2.5;
2156
+ // packages/facts/dist/src/components.js
2157
+ var FRAGMENT_VERSION = 2;
2158
+ var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2159
+ var portable = (value) => typeof value === "string" && value.length > 0 && !/^(?:\/|[A-Za-z]:)/.test(value) && !value.includes("\\");
2160
+ var componentFile = (file) => portable(file) && !file.split("/").some((part) => part === ".." || part === "." || part === "");
2161
+ var isComponentName = (name) => typeof name === "string" && /^[\p{Lu}\p{Lt}][\p{ID_Continue}$\u200C\u200D]*$/u.test(name);
2162
+ var encodePart = (part) => encodeURIComponent(part).replace(/%2F/g, "/").replace(/%40/g, "@").replace(/~/g, "%7E");
2163
+ function decodePart(part) {
2164
+ try {
2165
+ const decoded = decodeURIComponent(part);
2166
+ return encodePart(decoded) === part ? decoded : null;
2167
+ } catch {
2168
+ return null;
2169
+ }
2170
+ }
2171
+ function componentKey(name, file) {
2172
+ if (name !== "default" && !isComponentName(name) || !componentFile(file))
2173
+ throw new Error("invalid client component name or file");
2174
+ return `${name}@${encodePart(file)}`;
2175
+ }
2176
+ function parseComponentKey(key) {
2177
+ if (typeof key !== "string")
2178
+ return null;
2179
+ const separator = key.indexOf("@");
2180
+ const name = key.slice(0, separator);
2181
+ const file = decodePart(key.slice(separator + 1));
2182
+ if (separator < 1 || name !== "default" && !isComponentName(name) || !componentFile(file))
2183
+ return null;
2184
+ return componentKey(name, file) === key ? { name, file } : null;
2185
+ }
2186
+ function isComponentFragment(value) {
2187
+ 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))
2188
+ return false;
2189
+ return value.components.every((component) => {
2190
+ if (!object(component) || typeof component.key !== "string")
2191
+ return false;
2192
+ const parsed = parseComponentKey(component.key);
2193
+ return parsed !== null && parsed.name === component.name && parsed.file === component.file && Number.isInteger(component.line) && component.line > 0;
2194
+ });
2195
+ }
2196
+ function isComponentConfiguration(value) {
2197
+ return object(value) && value.version === FRAGMENT_VERSION && typeof value.directory === "string" && /^[a-zA-Z0-9-]+$/.test(value.directory);
2198
+ }
2199
+
2115
2200
  // packages/facts/dist/src/places.js
2201
+ var placeWithoutVisit = (id) => placeRef(id).stem.replace(/#(\d+|repeat)$/, "");
2116
2202
  var AFTER_3S = "+3s";
2117
2203
  var kindOf = (stem) => {
2118
2204
  if (stem === "*")
@@ -2136,16 +2222,97 @@ function placeRef(id) {
2136
2222
  const stem = after3s ? id.slice(0, -AFTER_3S.length) : id;
2137
2223
  return { id, kind: kindOf(stem), stem, segment: after3s ? "after3s" : "whole" };
2138
2224
  }
2225
+ var placeSegment = (name, file) => `${name}@${encodePart(file)}`;
2226
+ var buildPlaceId = (parts) => {
2227
+ if (parts.kind === "screen")
2228
+ return `screen:${parts.screen}#${parts.visit}`;
2229
+ if (parts.kind === "launch")
2230
+ return `launch:${parts.screen}`;
2231
+ return `${parts.kind}:${parts.screen}›${parts.parent ? parts.parent + "›" : ""}${parts.target}${parts.k ? "~" + parts.k : ""}`;
2232
+ };
2233
+ function parseClientKey(key) {
2234
+ const anonymous = key.startsWith("default@");
2235
+ const parsed = parseComponentKey(anonymous ? "Default" + key.slice(7) : key);
2236
+ if (!parsed)
2237
+ return null;
2238
+ const name = anonymous ? "default" : parsed.name;
2239
+ return placeSegment(name, parsed.file) === key ? { name, file: parsed.file } : null;
2240
+ }
2241
+ var decodePlaceSegment = (key) => {
2242
+ if (key === "(no screen)")
2243
+ return { key, name: key, file: null };
2244
+ const at = key.indexOf("@");
2245
+ if (at < 1 || at === key.length - 1)
2246
+ return null;
2247
+ try {
2248
+ const name = key.slice(0, at);
2249
+ const file = decodePart(key.slice(at + 1));
2250
+ if (file === null)
2251
+ return null;
2252
+ if (name !== "(outside app components)" && !parseClientKey(key))
2253
+ return null;
2254
+ return placeSegment(name, file) === key ? { key, name, file } : null;
2255
+ } catch {
2256
+ return null;
2257
+ }
2258
+ };
2259
+ var placeParts = (id) => {
2260
+ const stem = id.replace(/\+3s$/, "");
2261
+ const suffix = /#(\d+|repeat)$/.exec(stem);
2262
+ const base = suffix ? stem.slice(0, suffix.index) : stem;
2263
+ const occurrence = /~(\d+)$/.exec(base);
2264
+ const body = occurrence ? base.slice(0, occurrence.index) : base;
2265
+ const colon = body.indexOf(":");
2266
+ const kind = body.slice(0, colon);
2267
+ if (kind !== "screen" && kind !== "launch" && kind !== "press" && kind !== "drag")
2268
+ return null;
2269
+ const segments = body.slice(colon + 1).split("›");
2270
+ if (kind === "screen" || kind === "launch" ? segments.length !== 1 : segments.length < 2 || segments.length > 3)
2271
+ return null;
2272
+ const parts = segments.map(decodePlaceSegment);
2273
+ if (parts.some((p) => p === null))
2274
+ return null;
2275
+ if (parts.slice(1).some((p) => p.name === "(no screen)"))
2276
+ return null;
2277
+ if (parts.length === 3 && parts[1].name === "(outside app components)")
2278
+ return null;
2279
+ const screen = parts[0];
2280
+ const target = kind === "press" || kind === "drag" ? parts.at(-1) : null;
2281
+ return {
2282
+ kind,
2283
+ screen,
2284
+ target,
2285
+ parent: parts.length === 3 ? parts[1] : null,
2286
+ k: occurrence ? Number(occurrence[1]) : null,
2287
+ visit: suffix ? suffix[1] === "repeat" ? "repeat" : Number(suffix[1]) : null,
2288
+ outsideHost: target?.name === "(outside app components)" ? target.file : null
2289
+ };
2290
+ };
2139
2291
  // packages/facts/dist/src/frame.js
2140
2292
  var UNKNOWN_REFRESH_HZ = 60;
2141
2293
  var LIMIT_FRAME_MS = 1000 / 60;
2142
2294
  function frameBudgetMs(refreshHz) {
2143
2295
  return 1000 / (refreshHz !== null && refreshHz !== undefined && Number.isFinite(refreshHz) && refreshHz > 0 ? refreshHz : UNKNOWN_REFRESH_HZ);
2144
2296
  }
2297
+ // packages/facts/dist/src/profile-items.js
2298
+ function encodeProfileItems(rows, fullRowMs = FULL_ROW_MS) {
2299
+ const components = [], small = [];
2300
+ for (const row of rows) {
2301
+ if (row.selfMs === null || row.selfMs >= fullRowMs)
2302
+ components.push(row);
2303
+ else
2304
+ small.push([row.key ?? row.name, row.selfMs]);
2305
+ }
2306
+ small.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2307
+ return { components, ...small.length ? { small } : {} };
2308
+ }
2145
2309
  // packages/derive/dist/src/launch.js
2146
2310
  var obj = (v) => typeof v === "object" && v !== null ? v : {};
2147
2311
  var isScreenWindow = (w) => w.raw.kind === "screen" || w.name.startsWith("screen:");
2148
- var launchPlaceOf = (name) => `launch:${name.replace(/^screen:/, "").replace(/#\d+$/, "")}`;
2312
+ var launchPlaceOf = (name) => {
2313
+ const p = placeParts(name);
2314
+ return p ? buildPlaceId({ kind: "launch", screen: p.screen.key }) : `launch:${placeWithoutVisit(name).replace(/^screen:/, "")}`;
2315
+ };
2149
2316
  var isScreenName = (name) => name.startsWith("screen:") || name.startsWith("launch:");
2150
2317
  function withLaunchNames(windows) {
2151
2318
  const screens = windows.filter(isScreenWindow);
@@ -2154,6 +2321,9 @@ function withLaunchNames(windows) {
2154
2321
  const isLaunch = (w) => marked ? obj(w.raw.screen).launch === true : w === first;
2155
2322
  return windows.map((w) => isScreenWindow(w) && isLaunch(w) ? { ...w, name: launchPlaceOf(w.name) } : w);
2156
2323
  }
2324
+
2325
+ // packages/derive/dist/src/model.js
2326
+ var DERIVATION_VERSION = 27;
2157
2327
  // packages/stats/dist/src/core/rng.js
2158
2328
  function splitmix32(seed) {
2159
2329
  let s = seed >>> 0;
@@ -3130,7 +3300,7 @@ var obj3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v :
3130
3300
  var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
3131
3301
  var round = (x, digits = 3) => Math.round(x * 10 ** digits) / 10 ** digits;
3132
3302
  var HOST_COMPONENTS = new Set(["View", "Text", "Image", "TextInput", "ScrollView"]);
3133
- var isHostComponent = (name) => HOST_COMPONENTS.has(name) || /^RCT[A-Z]/.test(name);
3303
+ var isHostComponent = (name) => !parseComponentKey(name) && (HOST_COMPONENTS.has(name) || /^RCT[A-Z]/.test(name));
3134
3304
  function causeCounts(raw, mounts) {
3135
3305
  const c = obj3(raw);
3136
3306
  const out = {};
@@ -3150,7 +3320,7 @@ function causeCounts(raw, mounts) {
3150
3320
  }
3151
3321
  return out;
3152
3322
  }
3153
- function componentRow(row, causes) {
3323
+ function componentRow(row, causes, rollup, detail) {
3154
3324
  if (!Array.isArray(row) || typeof row[0] !== "string")
3155
3325
  return;
3156
3326
  const six = row.length >= 6;
@@ -3158,15 +3328,57 @@ function componentRow(row, causes) {
3158
3328
  const mounts = num2(row[2]) ?? 0;
3159
3329
  return {
3160
3330
  name: row[0],
3331
+ ...detail !== false && Object.hasOwn(rollup, row[0]) ? rollupOf(rollup[row[0]]) : {},
3161
3332
  renders,
3162
3333
  mounts,
3163
3334
  instances: six ? num2(row[3]) ?? null : null,
3164
3335
  selfMs: num2(row[six ? 4 : 3]) ?? null,
3165
3336
  totalMs: num2(row[six ? 5 : 4]) ?? null,
3166
- causes: causeCounts(causes[row[0]], mounts)
3337
+ causes: detail === false ? {} : causeCounts(causes[row[0]], mounts),
3338
+ ...detail === undefined ? {} : { detailRenders: detail ? renders : 0 }
3339
+ };
3340
+ }
3341
+ function rollupOf(raw) {
3342
+ const x = obj3(raw), rest = obj3(x.rolledUpRest);
3343
+ 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] }] : []);
3344
+ return {
3345
+ rolledUp,
3346
+ rolledUpRest: { ms: num2(rest.ms) ?? 0, n: num2(rest.n) ?? 0, ...rest.saturated === true ? { saturated: true } : {} },
3347
+ ...typeof x.parent === "string" ? { parent: x.parent, parentOccurrences: { [x.parent]: 1 } } : {},
3348
+ ...num2(x.rolledUpMs) === undefined ? {} : { rolledUpMs: num2(x.rolledUpMs) }
3349
+ };
3350
+ }
3351
+ function mergeRollups(a, b) {
3352
+ const names = new Map;
3353
+ for (const row of [...a.rolledUp ?? [], ...b.rolledUp ?? []]) {
3354
+ const was = names.get(row.name);
3355
+ names.set(row.name, { name: row.name, ms: (was?.ms ?? 0) + row.ms, renders: (was?.renders ?? 0) + row.renders });
3356
+ }
3357
+ const x = a.rolledUpRest, y = b.rolledUpRest;
3358
+ return { rolledUp: [...names.values()], rolledUpRest: {
3359
+ ms: (x?.ms ?? 0) + (y?.ms ?? 0),
3360
+ n: Math.max(x?.n ?? 0, y?.n ?? 0),
3361
+ ...x?.saturated || y?.saturated ? { saturated: true } : {},
3362
+ ...x?.nAtLeast || y?.nAtLeast || x?.n && y?.n ? { nAtLeast: true } : {}
3363
+ } };
3364
+ }
3365
+ function shownRollup(r, n) {
3366
+ const ranked = [...r.rolledUp ?? []].sort((a, b) => b.ms - a.ms || a.name.localeCompare(b.name));
3367
+ const cut = ranked.slice(5), rest = r.rolledUpRest;
3368
+ return {
3369
+ rolledUp: ranked.slice(0, 5).map((x) => ({ name: x.name, ms: round(x.ms / n), renders: round(x.renders / n) })),
3370
+ 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
3371
  };
3168
3372
  }
3169
- function windowProfile(raw) {
3373
+ function outsideOf(profiles, n) {
3374
+ const parts = profiles.flatMap((p) => p.outside ? [p.outside] : []);
3375
+ if (!parts.length)
3376
+ return {};
3377
+ const merged = parts.reduce((a, b) => mergeRollups(a, b), {});
3378
+ 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)) } };
3379
+ }
3380
+ var stampsOf = (ps) => ps.every((p) => p.stamped !== undefined) ? { stamped: ps.reduce((s, p) => s + p.stamped, 0) } : {};
3381
+ function windowProfile(raw, detailCoverage = true) {
3170
3382
  const p = obj3(obj3(raw.react).profile);
3171
3383
  if (p.renderer !== "profiling" && p.renderer !== "production")
3172
3384
  return;
@@ -3187,11 +3399,14 @@ function windowProfile(raw) {
3187
3399
  }
3188
3400
  return {
3189
3401
  renderer: p.renderer,
3402
+ ...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
3403
  commits: num2(p.commits) ?? 0,
3404
+ ...num2(p.stamped) === undefined ? {} : { stamped: num2(p.stamped) },
3405
+ ...p.outside && p.renderer === "profiling" ? { outside: { ...rollupOf(p.outside), selfMs: num2(obj3(p.outside).selfMs) ?? 0, mountSelfMs: num2(obj3(p.outside).mountSelfMs) ?? 0 } } : {},
3191
3406
  renderMs: p.renderer === "profiling" && sum !== undefined ? { sum, max: num2(r.max) ?? sum, n: num2(r.n) ?? 0 } : null,
3192
3407
  overBudget: Object.keys(overBudget).length ? overBudget : null,
3193
3408
  updaters,
3194
- components: (Array.isArray(p.components) ? p.components : []).map((row) => componentRow(row, causes)).filter((c) => !!c),
3409
+ 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
3410
  omitted: num2(p.omitted) ?? 0,
3196
3411
  selfMsTotal: num2(p.selfMsTotal) ?? null,
3197
3412
  mountSelfMs: num2(p.mountSelfMs) ?? null
@@ -3242,13 +3457,23 @@ function addComponent(into, c) {
3242
3457
  into.set(c.name, {
3243
3458
  name: c.name,
3244
3459
  renders: was.renders + c.renders,
3460
+ ...was.detailRenders === undefined && c.detailRenders === undefined ? {} : { detailRenders: (was.detailRenders ?? was.renders) + (c.detailRenders ?? c.renders) },
3245
3461
  mounts: was.mounts + c.mounts,
3246
3462
  instances: was.instances === null && c.instances === null ? null : Math.max(was.instances ?? 0, c.instances ?? 0),
3247
3463
  selfMs: plus(was.selfMs, c.selfMs),
3248
3464
  totalMs: plus(was.totalMs, c.totalMs),
3249
- causes
3465
+ causes,
3466
+ ...was.rolledUpMs === undefined && c.rolledUpMs === undefined ? {} : { rolledUpMs: (was.rolledUpMs ?? 0) + (c.rolledUpMs ?? 0), ...mergeRollups(was, c) },
3467
+ ...mergedParent(was, c)
3250
3468
  });
3251
3469
  }
3470
+ function mergedParent(a, b) {
3471
+ const counts = { ...a.parentOccurrences };
3472
+ for (const [k, v] of Object.entries(b.parentOccurrences ?? {}))
3473
+ counts[k] = (counts[k] ?? 0) + v;
3474
+ const parent = Object.keys(counts).sort((a, b) => counts[b] - counts[a] || a.localeCompare(b))[0];
3475
+ return parent ? { parent, parentOccurrences: counts } : {};
3476
+ }
3252
3477
  function exactMountOf(profiles) {
3253
3478
  let selfMsTotal = 0;
3254
3479
  let mountSelfMs = 0;
@@ -3262,17 +3487,20 @@ function exactMountOf(profiles) {
3262
3487
  }
3263
3488
  return { selfMsTotal, mountSelfMs };
3264
3489
  }
3265
- function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = false) {
3490
+ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = false, allComponents = false) {
3266
3491
  const picked = [];
3267
3492
  for (const cl of clusters(windows)) {
3268
3493
  const withProfile = cl.flatMap((w) => {
3269
- const p = windowProfile(w.raw);
3494
+ const p = windowProfile(w.raw, allComponents);
3270
3495
  return p ? [{ w, p }] : [];
3271
3496
  });
3272
3497
  if (!withProfile.length)
3273
3498
  continue;
3274
3499
  const weight = (x) => x.p.renderMs?.sum ?? x.p.components.reduce((s, c) => s + c.renders, 0);
3275
- picked.push(withProfile.reduce((best, x) => weight(x) > weight(best) || weight(x) === weight(best) && x.w.endWall - x.w.startWall > best.w.endWall - best.w.startWall ? x : best));
3500
+ picked.push(withProfile.reduce((best, x) => {
3501
+ const span = (y) => y.w.endWall - y.w.startWall;
3502
+ 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;
3503
+ }));
3276
3504
  }
3277
3505
  if (!picked.length)
3278
3506
  return;
@@ -3295,10 +3523,13 @@ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = fal
3295
3523
  overFrame = f === undefined || overFrame === undefined ? undefined : overFrame + f;
3296
3524
  }
3297
3525
  }
3526
+ const chosen = [...picked].sort((a, b) => (b.p.renderMs?.max ?? -1) - (a.p.renderMs?.max ?? -1) || windowOrder(a.w, b.w))[0];
3298
3527
  const renderMs = timed ? picked.reduce((s, x) => s + (x.p.renderMs?.sum ?? 0), 0) : allIdle ? 0 : undefined;
3299
3528
  const parts = picked.map((x) => timerCarriedRenderMs(x.w.raw));
3300
3529
  const carried = parts.every((v) => v !== undefined) ? parts.reduce((s, v) => s + v, 0) : undefined;
3301
3530
  return {
3531
+ window: chosen.w,
3532
+ ...chosen.p.slowestCommit ? { slowestCommit: chosen.p.slowestCommit } : {},
3302
3533
  renderer: timed ? "profiling" : "production",
3303
3534
  commits: picked.reduce((s, x) => s + x.p.commits, 0),
3304
3535
  renderMs,
@@ -3308,20 +3539,24 @@ function occurrence(windows, budgetMs, idleNeutral = false, frameMs, upper = fal
3308
3539
  updaters,
3309
3540
  components,
3310
3541
  omitted: picked.reduce((s, x) => s + x.p.omitted, 0),
3311
- distinctInWindow: Math.max(...picked.map((x) => x.p.components.length + x.p.omitted)),
3542
+ distinctInWindow: Math.max(...picked.map((x) => (allComponents ? x.p.components.filter((c) => !isHostComponent(c.name)).length : x.p.components.length) + x.p.omitted)),
3312
3543
  exactMount: exactMountOf(picked.map((x) => x.p)),
3544
+ ...outsideOf(picked.map((x) => x.p)),
3545
+ ...stampsOf(picked.map((x) => x.p)),
3313
3546
  ...allIdle ? { idle: true } : {},
3314
3547
  ...upper ? { timerCarriedMs: carried, renderMsUpper: renderMs === undefined || carried === undefined ? undefined : renderMs + carried } : {}
3315
3548
  };
3316
3549
  }
3317
3550
  var UPDATERS_KEPT = 5;
3551
+ var CPU_FUNCTIONS_KEPT = 10;
3552
+ var UNTIMED_COMPONENTS_KEPT = 10;
3318
3553
  var topCauses = (causes, renders) => {
3319
3554
  if (renders <= 0)
3320
3555
  return {};
3321
3556
  const entries = Object.entries(causes).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3);
3322
3557
  return Object.fromEntries(entries.map(([k, v]) => [k, round(v / renders)]));
3323
3558
  };
3324
- function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
3559
+ function stepProfile(step, occ, budgetMs, topN, cpu, exactMount, fullRowMs) {
3325
3560
  const n = occ.length;
3326
3561
  const mean = (x) => x / n;
3327
3562
  const timed = occ.every((o) => o.renderer === "profiling");
@@ -3334,8 +3569,9 @@ function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
3334
3569
  const host = [...all.values()].filter((c) => isHostComponent(c.name));
3335
3570
  const rank = (a, b) => (timed ? (b.selfMs ?? 0) - (a.selfMs ?? 0) : 0) || b.renders - a.renders || a.name.localeCompare(b.name);
3336
3571
  own.sort(rank);
3337
- const kept = own.slice(0, topN);
3338
- const rest = own.slice(topN);
3572
+ const limit = fullRowMs === undefined ? topN : timed ? own.length : UNTIMED_COMPONENTS_KEPT;
3573
+ const kept = own.slice(0, limit);
3574
+ const rest = own.slice(limit);
3339
3575
  const ms = (x) => timed && x !== null ? round(mean(x)) : null;
3340
3576
  const updaters = {};
3341
3577
  for (const o of occ)
@@ -3361,31 +3597,36 @@ function stepProfile(step, occ, budgetMs, topN, cpu, exactMount) {
3361
3597
  cpuOut = {
3362
3598
  busyMs: round(median(cpu.map((c) => c.busyMs))),
3363
3599
  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) }))
3600
+ 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
3601
  };
3366
3602
  }
3603
+ const rows = kept.map((c) => ({
3604
+ key: c.name,
3605
+ ...c.parent ? { parent: c.parent } : {},
3606
+ ...c.rolledUpMs === undefined ? {} : { rolledUpMs: round(mean(c.rolledUpMs)), ...shownRollup(c, n) },
3607
+ name: c.name,
3608
+ renders: round(mean(c.renders)),
3609
+ mounts: round(mean(c.mounts)),
3610
+ instances: c.instances,
3611
+ selfMs: ms(c.selfMs),
3612
+ totalMs: ms(c.totalMs),
3613
+ causes: topCauses(c.causes, c.detailRenders ?? c.renders)
3614
+ }));
3367
3615
  return {
3368
3616
  step,
3369
3617
  occurrences: n,
3618
+ ...outsideOf(occ, n),
3619
+ ...stampsOf(occ),
3370
3620
  renderer,
3371
3621
  frameBudgetMs: round(budgetMs, 1),
3372
3622
  commits: round(mean(commits)),
3373
3623
  renderMs: timed ? round(mean(occ.reduce((s, o) => s + (o.renderMs ?? 0), 0))) : null,
3374
3624
  commitMaxMs: timed ? round(Math.max(...occ.map((o) => o.commitMaxMs ?? 0))) : null,
3375
- components: kept.map((c) => ({
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
- })),
3625
+ ...fullRowMs !== undefined && timed ? encodeProfileItems(rows, fullRowMs) : { components: rows },
3385
3626
  host: { renders: round(mean(host.reduce((s, c) => s + c.renders, 0))), selfMs: ms(host.reduce((s, c) => s + (c.selfMs ?? 0), 0)) },
3386
3627
  omitted: {
3387
3628
  count: rest.length,
3388
- selfMs: ms(rest.reduce((s, c) => s + (c.selfMs ?? 0), 0)),
3629
+ 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
3630
  sdkOmitted: occ.reduce((s, o) => s + o.omitted, 0)
3390
3631
  },
3391
3632
  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 +3664,7 @@ function profileMetrics(windows, steps, options = {}) {
3423
3664
  for (const [name, all] of [...byName].sort((a, b) => a[0].localeCompare(b[0])))
3424
3665
  groups.push([name, all.map((w) => [w])]);
3425
3666
  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);
3667
+ 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
3668
  const decided = found.some((o) => !o.idle) && found.every((o) => o.idle || o.renderer === "profiling");
3428
3669
  const occ = decided ? found.map((o) => o.idle ? { ...o, renderer: "profiling" } : o) : found;
3429
3670
  const cpu = options.cpu?.get(key);
@@ -3438,10 +3679,21 @@ function profileMetrics(windows, steps, options = {}) {
3438
3679
  if (vals.length && vals.every((v) => v !== undefined))
3439
3680
  metrics.set(`${key}.${f}`, median(vals));
3440
3681
  }
3441
- profiles.push(stepProfile(key, occ, budget, topN, cpu, options.exactMount === true));
3682
+ const profile = stepProfile(key, occ, budget, topN, cpu, options.exactMount === true, options.fullRowMs);
3683
+ const kind = placeRef(key).kind;
3684
+ const active = occ.filter((o) => !o.idle);
3685
+ 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);
3686
+ const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && ["screen", "press", "drag", "other"].includes(kind) && selected?.slowestCommit;
3687
+ const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
3688
+ profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
3442
3689
  }
3443
3690
  return { metrics, profiles };
3444
3691
  }
3692
+ 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);
3693
+ function middleBy(items, time, id, t0) {
3694
+ const ordered = [...items].sort((a, b) => (time(a) ?? -1) - (time(b) ?? -1) || id(a) - id(b) || t0(a) - t0(b));
3695
+ return ordered[Math.floor((ordered.length - 1) / 2)];
3696
+ }
3445
3697
 
3446
3698
  // packages/derive/dist/src/facts.js
3447
3699
  var ROUTE_CHANGE_ROW = "route_change";
@@ -4287,6 +4539,14 @@ function v6Metrics(windows, samples, t0, t1, version) {
4287
4539
  out.set("sampler.thermal.start", thermal[0]);
4288
4540
  out.set("sampler.thermal.hot_share", thermal.filter((v) => v >= 2).length / thermal.length);
4289
4541
  }
4542
+ if (version >= 27) {
4543
+ const raw = inRun.map((s) => num7(s.raw.thermalRaw)).filter((v) => v !== undefined);
4544
+ if (raw.length) {
4545
+ out.set("sampler.thermal.raw_start", raw[0]);
4546
+ out.set("sampler.thermal.raw_max", Math.max(...raw));
4547
+ out.set("sampler.thermal.raw_light_share", raw.filter((v) => v >= 1).length / raw.length);
4548
+ }
4549
+ }
4290
4550
  const hz = inRun.map((s) => num7(obj7(s.raw.frames).maxHz)).filter((v) => v !== undefined);
4291
4551
  if (hz.length)
4292
4552
  out.set("sampler.max_hz", Math.max(...hz));
@@ -4601,12 +4861,14 @@ function endsHome(windows, endCommandWall) {
4601
4861
  }
4602
4862
  function endMetrics(markers, events, samples, windows, launchMb, opts = {}) {
4603
4863
  const out = new Map;
4604
- const n = markers.some((m) => m.phase === "start") ? runEndCommand(markers, events, windows) : null;
4864
+ const declared = markers.some((m) => m.phase === "start");
4865
+ const readsEnd = declared || opts.markerless === true && events.gc.length > 0;
4866
+ const n = readsEnd ? runEndCommand(markers, events, windows) : null;
4605
4867
  const command = n === null ? undefined : events.gc.find((g) => g.n === n && g.status === "ok");
4606
- const home = endsHome(windows, command?.wall);
4868
+ const home = endsHome(windows, declared ? command?.wall : undefined);
4607
4869
  if (home !== undefined)
4608
4870
  out.set("sdk.journey_ends_home", home);
4609
- if (!markers.some((m) => m.phase === "start"))
4871
+ if (!readsEnd)
4610
4872
  return out;
4611
4873
  out.set("sdk.end_gc", command ? 1 : 0);
4612
4874
  const g = n === null ? undefined : cleanOf(events, windows).get(n);
@@ -4614,6 +4876,8 @@ function endMetrics(markers, events, samples, windows, launchMb, opts = {}) {
4614
4876
  return out;
4615
4877
  const post = g.post === undefined ? undefined : footprintsBySeq(samples).get(g.post);
4616
4878
  const manual = opts.manualRead ? manualReadFootprint(samples, g.n) : undefined;
4879
+ if (!declared && manual === undefined)
4880
+ return out;
4617
4881
  const footprint = manual ?? post;
4618
4882
  if (footprint !== undefined) {
4619
4883
  if (opts.manualRead)
@@ -4678,8 +4942,7 @@ function keyFirstOpens(windows) {
4678
4942
  }
4679
4943
 
4680
4944
  // packages/derive/dist/src/derive.js
4681
- var DERIVATION_VERSION = 23;
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];
4945
+ 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
4946
  var windowsForVersion = (windows, version = DERIVATION_VERSION) => version >= 22 ? keyFirstOpens(windows) : windows;
4684
4947
  function stepWindowsFor(windows, version = DERIVATION_VERSION) {
4685
4948
  return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version);
@@ -4698,6 +4961,22 @@ function samplerFrameBudgetMs(samples) {
4698
4961
  }
4699
4962
  return hz > 1 ? frameBudgetMs(Math.round(hz)) : undefined;
4700
4963
  }
4964
+ function profileOptions(windows, version, processWindows = windows) {
4965
+ if (version < 26)
4966
+ return {};
4967
+ const processes = new Set(processWindows.map((w) => w.raw.react?.causes?.process).filter((p) => typeof p === "string"));
4968
+ 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));
4969
+ const openedBy = new Map;
4970
+ for (const screen of windows.filter((w) => placeRef(w.name).kind === "screen")) {
4971
+ const start = appClock(screen)[0], opener = presses.find((w) => {
4972
+ const [from, to] = appClock(w);
4973
+ return from <= start && start < to;
4974
+ });
4975
+ if (opener)
4976
+ openedBy.set(screen, opener.name);
4977
+ }
4978
+ return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
4979
+ }
4701
4980
  function deriveRun(input) {
4702
4981
  const { run, markers = [], ri, clockOffsetMs, samples = [], memWindow } = input;
4703
4982
  const version = input.derivationVersion ?? DERIVATION_VERSION;
@@ -4719,7 +4998,9 @@ function deriveRun(input) {
4719
4998
  return { rows, steps, run: final, profiles: [], derivationVersion: version };
4720
4999
  const budget = input.frameBudgetMs ?? (version >= 6 ? samplerFrameBudgetMs(samplesInRun(samples, final.t0, final.t1)) : undefined);
4721
5000
  const compareMs = version >= 20 ? LIMIT_FRAME_MS : budget ?? frameBudgetMs();
5001
+ const allComponents = profileOptions(windows, version, input.windows);
4722
5002
  const prof = profileMetrics(windows, steps, {
5003
+ ...allComponents,
4723
5004
  frameBudgetMs: version >= 11 ? compareMs : Number(compareMs.toFixed(1)),
4724
5005
  ...version >= 12 ? { countFrameMs: LIMIT_FRAME_MS } : {},
4725
5006
  ...cpu === undefined ? {} : { cpu },
@@ -4729,6 +5010,7 @@ function deriveRun(input) {
4729
5010
  ...version >= 16 ? { upperBound: true } : {}
4730
5011
  });
4731
5012
  const bg = backgroundMetrics(background, {
5013
+ ...allComponents,
4732
5014
  frameBudgetMs: compareMs,
4733
5015
  countFrameMs: LIMIT_FRAME_MS,
4734
5016
  ...input.profileTopN === undefined ? {} : { topN: input.profileTopN },
@@ -4759,7 +5041,7 @@ function deriveRun(input) {
4759
5041
  if (version >= 19) {
4760
5042
  const events = input.events ?? { gc: [], appState: [] };
4761
5043
  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)])
5044
+ 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
5045
  extra.set(k, v);
4764
5046
  }
4765
5047
  const base = { run_id: final.run_id, arm: final.arm, block: final.block, valid: final.valid, invalid_reason: final.invalid_reason };
@@ -4898,7 +5180,7 @@ async function call(f, base, path, headers, body, method = "POST") {
4898
5180
  try {
4899
5181
  return text ? JSON.parse(text) : {};
4900
5182
  } catch {
4901
- return {};
5183
+ throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
4902
5184
  }
4903
5185
  }
4904
5186
  async function uploadRuns(deps, outDir, runs, opts) {
@@ -4962,44 +5244,24 @@ async function uploadRuns(deps, outDir, runs, opts) {
4962
5244
  return { uploaded, skipped };
4963
5245
  }
4964
5246
 
4965
- // packages/present/dist/src/places.js
4966
- var PLACE_KIND_WORDS = {
4967
- screen: "Screen",
4968
- launch: "Launch screen",
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)$/;
5247
+ // packages/present/dist/src/capital.js
5248
+ var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5249
+
5250
+ // packages/present/dist/src/placeWords.js
4993
5251
  var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
4994
5252
  var ORDINAL_SUFFIXES = ["th", "st", "nd", "rd"];
4995
5253
  var LAST_TEEN = 13;
4996
5254
  var FIRST_TEEN = 11;
4997
5255
  var words = (s) => s.replace(/[._\-]+/g, " ").replace(/\s+/g, " ").trim();
4998
5256
  var cap = (s) => s ? s[0].toUpperCase() + s.slice(1) : s;
5257
+ var nameWords = (s) => cap(words(s));
4999
5258
  var ordinal = (n) => {
5000
5259
  const inTeens = n % 100 >= FIRST_TEEN && n % 100 <= LAST_TEEN;
5001
5260
  return `${n}${inTeens ? "th" : ORDINAL_SUFFIXES[n % 10] ?? "th"}`;
5002
5261
  };
5262
+ function routeFileName(path) {
5263
+ return path.split("/").filter((p) => p && !/^\(.*\)$/.test(p) && p !== "index").map(nameWords).join(" ") || "App start";
5264
+ }
5003
5265
  function screenName(path) {
5004
5266
  const parts = path.split("/").filter((p) => p && p !== "__root");
5005
5267
  const plain = parts.filter((p) => !/^\(.*\)$/.test(p) && !/^\[.*\]$/.test(p) && p !== "index");
@@ -5011,50 +5273,7 @@ function screenName(path) {
5011
5273
  const name = cap(words(leaf));
5012
5274
  return group && !GENERIC_GROUPS.has(group) && !plain.slice(0, -1).length ? `${cap(words(group))} › ${name}` : name;
5013
5275
  }
5014
- function stemName(stem, kind) {
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
- }
5276
+
5058
5277
  // packages/present/dist/src/states.js
5059
5278
  var STATE_WORDS = {
5060
5279
  worse: "Worse",
@@ -5064,7 +5283,10 @@ var STATE_WORDS = {
5064
5283
  "cant-tell": "Can’t tell"
5065
5284
  };
5066
5285
  var INFO_WORDS = { heat: "Phone heated up" };
5286
+ var isWorseState = (state) => state === "worse";
5067
5287
  var NOT_BLOCKING = "not blocking";
5288
+ var badgedWord = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? `${word} (${NOT_BLOCKING})` : word;
5289
+
5068
5290
  // packages/present/dist/src/values.js
5069
5291
  var NO_VALUE = "–";
5070
5292
  var FALLBACK_DECIMALS = 1;
@@ -5120,14 +5342,425 @@ function fieldValueText(field, v) {
5120
5342
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
5121
5343
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
5122
5344
  }
5345
+
5346
+ // packages/present/dist/src/rowWords.js
5347
+ var GONE_TAILS = {
5348
+ branch: { notInRun: "on main, not in this run", removed: "on main, not rendered in this run" },
5349
+ journey: { notInRun: "in the sessions before, not in the newest session", removed: "in the sessions before, not rendered in the newest session" }
5350
+ };
5351
+ var ROW_STATE_WORDS = {
5352
+ new: "New in this PR: not compared with main",
5353
+ notInRun: upperFirst(GONE_TAILS.branch.notInRun),
5354
+ removed: upperFirst(GONE_TAILS.branch.removed)
5355
+ };
5356
+ var JOURNEY_ROW_WORDS = {
5357
+ new: "New in the newest session: not compared with the sessions before",
5358
+ notInRun: upperFirst(GONE_TAILS.journey.notInRun),
5359
+ removed: upperFirst(GONE_TAILS.journey.removed)
5360
+ };
5361
+ var NEW_IN_PR_CHIP = "New in this PR";
5362
+ var placeNamesChangedTitle = (sdk) => `Place names changed with SDK ${sdk}`;
5363
+ var restartRowText = (sdk, mainSessions, needed) => `${placeNamesChangedTitle(sdk)}: ${restartContextText(mainSessions, needed)}`;
5364
+ var restartContextText = (mainSessions, needed) => `main has ${mainSessions} of ${needed} sessions on the new names`;
5365
+ function removedRowsText(n, page) {
5366
+ return { lead: `${plural(n, "component", "components")} ${GONE_TAILS[page].removed}:`, rest: " not counted." };
5367
+ }
5368
+ function notInRunRowsText(c, page) {
5369
+ const what = [c.screens ? plural(c.screens, "screen", "screens") : "", c.places ? `${c.places} ${c.places === 1 ? "tap or scroll" : "taps and scrolls"}` : ""].filter(Boolean).join(" and ");
5370
+ const them = c.screens + c.places === 1 ? "it" : "them";
5371
+ return { lead: `${what} ${GONE_TAILS[page].notInRun}:`, rest: ` the ${page === "journey" ? "newest session" : "journey"} did not reach ${them}; not counted.` };
5372
+ }
5373
+ var OUTSIDE_APP_NAME = "Outside app components";
5374
+ var OTHER_RENDER_WORK_NAME = "Other render work";
5375
+ var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
5376
+ var rolledUpText = (rolledUpMs, totalMs) => `From libraries: ${valueText("self_ms", rolledUpMs)} of ${valueText("self_ms", totalMs)}`;
5377
+ var libraryLineText = (l) => `${l.name} · ${l.renders} rendered · ${valueText("self_ms", l.ms)}`;
5378
+ var rolledUpRestText = (rest) => `${plural(rest.n, "other library", "other libraries")} · ${valueText("self_ms", rest.ms)}`;
5379
+ var measureWords = (measure) => isMeasureId(measure) ? MEASURES[measure].label : `component ${BREAKDOWN_MEASURES[measure].label}`;
5380
+ var TIER_STATE = { worse: "worse", slight: "warning", better: "better", same: "no-change" };
5381
+ function leadBody(subject, measure, main, pr) {
5382
+ const unit = shownUnit(isMeasureId(measure) ? MEASURES[measure].unit : BREAKDOWN_MEASURES[measure].unit).unit;
5383
+ const to = valueText(measure, pr);
5384
+ const from = valueText(measure, main);
5385
+ const bare = unit && from.endsWith(` ${unit}`) && to.endsWith(` ${unit}`) ? from.slice(0, from.length - unit.length - 1) : from;
5386
+ return `${subject}, ${measureWords(measure)} ${bare} → ${to}`;
5387
+ }
5388
+ var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5389
+ var FOR_INFORMATION = "For information";
5390
+ var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5391
+ function foldedText(n, rows, page) {
5392
+ return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5393
+ }
5394
+ function placeShownName(p, o = {}) {
5395
+ const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5396
+ const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
5397
+ return o.onScreen ? `${named} on ${o.onScreen}` : named;
5398
+ }
5399
+ var libraryViewName = (kind, hostType) => `${kind === "drag" ? "Scroll" : "Tap"} outside app components (${hostType})`;
5400
+ function componentShownName(name, file) {
5401
+ if (name !== "default" || !file)
5402
+ return name;
5403
+ const path = file.replace(/\.[^.\/]+$/, "");
5404
+ const route = /^(?:src\/)?app\/(.*)$/.exec(path)?.[1];
5405
+ const parts = path.split("/");
5406
+ const stem = parts.at(-1) ?? "";
5407
+ if (route !== undefined && stem !== "_layout")
5408
+ return routeFileName(route);
5409
+ const label = stem === "index" || stem === "_layout" ? parts.at(-2) ?? stem : stem;
5410
+ return nameWords(label);
5411
+ }
5412
+
5413
+ // packages/present/dist/src/places.js
5414
+ var PLACE_KIND_WORDS = {
5415
+ screen: "Screen",
5416
+ launch: "Launch screen",
5417
+ tap: "Tap",
5418
+ scroll: "Scroll",
5419
+ step: "Test step",
5420
+ journey: "Whole journey",
5421
+ background: "Background",
5422
+ after3s: "3 s after"
5423
+ };
5424
+ var KIND_WORD = {
5425
+ journey: PLACE_KIND_WORDS.journey,
5426
+ screen: PLACE_KIND_WORDS.screen,
5427
+ launch: PLACE_KIND_WORDS.launch,
5428
+ press: PLACE_KIND_WORDS.tap,
5429
+ drag: PLACE_KIND_WORDS.scroll,
5430
+ step: PLACE_KIND_WORDS.step,
5431
+ background: PLACE_KIND_WORDS.background
5432
+ };
5433
+ var BACKGROUND_SOURCES = {
5434
+ websocket: "data pushed over a WebSocket",
5435
+ launch: "work started at app launch",
5436
+ link: "work of a deep link outside its screen",
5437
+ touch: "work of a tap outside its window",
5438
+ input: "work of typing outside its window"
5439
+ };
5440
+ var OPEN_WORDS = { first: "first time", repeat: "again" };
5441
+ var OPEN_MARK = /#(1|repeat)$/;
5442
+ var isInputKind = (kind) => kind === "press" || kind === "drag";
5443
+ function placeOpen(id) {
5444
+ const ref = placeRef(id);
5445
+ if (!isInputKind(ref.kind))
5446
+ return null;
5447
+ const m = OPEN_MARK.exec(ref.stem);
5448
+ return m ? m[1] === "repeat" ? "repeat" : "first" : null;
5449
+ }
5450
+ var placeOpenWord = (id) => {
5451
+ const open = placeOpen(id);
5452
+ return open ? OPEN_WORDS[open] : null;
5453
+ };
5454
+ function placeIdShownName(id, o = {}) {
5455
+ const p = placeParts(id);
5456
+ if (!p)
5457
+ return null;
5458
+ const component = (part) => ({ name: part.name, file: part.file ?? "" });
5459
+ const screen = p.screen.name === "(no screen)" ? "" : p.screen.name === "(outside app components)" ? screenName(p.screen.file ?? "") : componentShownName(p.screen.name, p.screen.file ?? "");
5460
+ if (p.kind === "screen" || p.kind === "launch")
5461
+ return screen + (p.kind === "screen" && typeof p.visit === "number" && p.visit > 1 ? ` (${ordinal(p.visit)} visit)` : "");
5462
+ 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 });
5463
+ }
5464
+ var isLibraryPlace = (id) => placeParts(id)?.outsideHost != null;
5465
+ function stemName(stem, kind) {
5466
+ const shown = placeIdShownName(stem);
5467
+ if (shown !== null)
5468
+ return isLibraryPlace(stem) ? shown : `${KIND_WORD[kind]}: ${shown}`;
5469
+ if (kind === "journey")
5470
+ return PLACE_KIND_WORDS.journey;
5471
+ if (kind === "other")
5472
+ return words(stem);
5473
+ const rest = stem.slice(stem.indexOf(":") + 1).replace(kind === "press" || kind === "drag" ? OPEN_MARK : /$^/, "");
5474
+ if (kind === "background")
5475
+ return `${KIND_WORD.background}: ${BACKGROUND_SOURCES[rest] ?? words(rest)}`;
5476
+ if (kind === "launch")
5477
+ return `${KIND_WORD.launch}: ${screenName(rest)}`;
5478
+ if (kind !== "screen")
5479
+ return `${KIND_WORD[kind]}: ${words(rest)}`;
5480
+ const visitMark = /^(.*)#(\d+)$/.exec(rest);
5481
+ const visit = visitMark ? Number(visitMark[2]) : 1;
5482
+ return `${PLACE_KIND_WORDS.screen}: ${screenName(visitMark ? visitMark[1] : rest)}${visit > 1 ? ` (${ordinal(visit)} visit)` : ""}`;
5483
+ }
5484
+ function windowName(id) {
5485
+ if (id === "flow")
5486
+ return PLACE_KIND_WORDS.journey;
5487
+ const ref = placeRef(id);
5488
+ const name = stemName(ref.stem, ref.kind);
5489
+ return ref.segment === "after3s" ? `${name} · ${PLACE_KIND_WORDS.after3s}` : name;
5490
+ }
5491
+ function placeKindWord(id) {
5492
+ if (id === "flow")
5493
+ return PLACE_KIND_WORDS.journey;
5494
+ const { kind } = placeRef(id);
5495
+ return kind === "other" ? "Window" : KIND_WORD[kind];
5496
+ }
5497
+ function windowQualified(id) {
5498
+ const word = placeOpenWord(id);
5499
+ const label = placeLabel(id);
5500
+ if (!word)
5501
+ return label;
5502
+ const after = placeRef(id).segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5503
+ return `${after ? label.slice(0, -after.length) : label} (${word})${after}`;
5504
+ }
5505
+ var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
5506
+ var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
5507
+ function placeLabel(id, screen) {
5508
+ if (id === "flow")
5509
+ return PLACE_KIND_WORDS.journey;
5510
+ const ref = placeRef(id);
5511
+ const after = ref.segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5512
+ const shown = placeIdShownName(ref.stem);
5513
+ if (shown !== null)
5514
+ return isLibraryPlace(ref.stem) ? `${shown}${after}` : `${KIND_WORD[ref.kind]} · ${shown}${after}`;
5515
+ if (ref.kind === "press" || ref.kind === "drag" || ref.kind === "step") {
5516
+ const element = elementWords(ref.stem.slice(ref.stem.indexOf(":") + 1).replace(OPEN_MARK, ""));
5517
+ return `${KIND_WORD[ref.kind]} · “${element}”${screen ? ` · ${screen}` : ""}${after}`;
5518
+ }
5519
+ const name = windowName(ref.stem);
5520
+ const colon = name.indexOf(": ");
5521
+ return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
5522
+ }
5523
+ // packages/present/dist/src/placeNames.js
5524
+ function shortestPaths(files) {
5525
+ const out = new Map;
5526
+ for (const file of files) {
5527
+ const parts = file.split("/");
5528
+ let shown = file;
5529
+ for (let k = 1;k <= parts.length; k++) {
5530
+ const tail = parts.slice(-k).join("/");
5531
+ if (!files.some((f) => f !== file && f.split("/").slice(-k).join("/") === tail)) {
5532
+ shown = tail;
5533
+ break;
5534
+ }
5535
+ }
5536
+ out.set(file, shown);
5537
+ }
5538
+ return out;
5539
+ }
5123
5540
  // packages/present/dist/src/stability.js
5124
5541
  var UNSTABLE_WORDS = {
5125
5542
  short: "main varies too much here",
5126
5543
  label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
5127
5544
  line: "Main varies too much here to tell",
5128
5545
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5546
+ note: "This place varies a lot between runs. Worth checking why: it may be in the code.",
5129
5547
  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
5548
  };
5549
+
5550
+ // packages/present/dist/src/present.js
5551
+ var ASPECT_WORDS = {
5552
+ renderer: "React renderer",
5553
+ reactExport: "React export",
5554
+ causeSplit: "work split by its cause",
5555
+ windowModel: "window model",
5556
+ screenWindowModel: "screen window model",
5557
+ cpuSource: "CPU source",
5558
+ sdk: "SDK version",
5559
+ driver: "driver",
5560
+ driverVersion: "driver version",
5561
+ journeyVersion: "journey version",
5562
+ actionGc: "memory clean-up before each step",
5563
+ endGc: "memory clean-up at the journey end",
5564
+ cpuProfiling: "CPU profiling"
5565
+ };
5566
+ var measuredDifferentlyText = (aspects) => `measured differently (${aspects.map((a) => ASPECT_WORDS[a] ?? a).join(", ")})`;
5567
+
5568
+ // packages/present/dist/src/readWords.js
5569
+ function componentReadName(c) {
5570
+ const ref = (c.key ? parseComponentKey(c.key) : null) ?? parseComponentKey(c.name);
5571
+ return componentShownName(ref?.name ?? c.name, c.file ?? ref?.file ?? "");
5572
+ }
5573
+ function componentReadNames(cs) {
5574
+ const names = cs.map(componentReadName);
5575
+ const rawNames = cs.map((c) => (c.key ? parseComponentKey(c.key)?.name : undefined) ?? parseComponentKey(c.name)?.name ?? c.name);
5576
+ return cs.map((c, i) => {
5577
+ const equals = cs.filter((_, k) => rawNames[k] === rawNames[i]);
5578
+ const file = c.file ?? (c.key ? parseComponentKey(c.key)?.file : undefined);
5579
+ const files = equals.flatMap((x) => x.file ?? (x.key ? parseComponentKey(x.key)?.file ?? [] : []));
5580
+ return new Set(files).size > 1 && file ? `${names[i]} · ${shortestPaths(files).get(file)}` : names[i];
5581
+ });
5582
+ }
5583
+ function storedRefWords(ref, peers = []) {
5584
+ if (!ref.startsWith("b:component:"))
5585
+ return ref;
5586
+ const cut = ref.lastIndexOf(":");
5587
+ const key = ref.slice("b:component:".length, cut);
5588
+ const components = [ref, ...peers].filter((r) => r.startsWith("b:component:")).map((r) => {
5589
+ const componentKey = r.slice("b:component:".length, r.lastIndexOf(":"));
5590
+ return { key: componentKey, name: componentKey };
5591
+ });
5592
+ const name = componentReadNames(components)[0] ?? componentReadName({ key, name: key });
5593
+ return `${name}, ${measureWords(ref.slice(cut + 1))}`;
5594
+ }
5595
+ function resultContextLines(c, verbose = false, short = false) {
5596
+ if (!c)
5597
+ return [];
5598
+ const out = [];
5599
+ if (c.namesChanged)
5600
+ out.push(restartRowText(c.namesChanged.sdk, c.namesChanged.mainSessions, c.namesChanged.needed));
5601
+ for (const w of c.windows) {
5602
+ const shownPlace = `${windowQualified(w.place)}${verbose ? ` (${w.place})` : ""}`;
5603
+ if (w.slowestCommit)
5604
+ out.push(`${shownPlace}: ${MEASURES.commit_max_ms.label}: ${valueText("commit_max_ms", w.slowestCommit.main)} → ${valueText("commit_max_ms", w.slowestCommit.pr)}`);
5605
+ if (!w.cause)
5606
+ continue;
5607
+ const s = w.cause.subject;
5608
+ const subject = s.kind === "component" ? `${componentShownName(s.ref.name, s.ref.file)}${verbose ? ` (${s.ref.file})` : ""}` : placeShownName({ kind: placeParts(s.place)?.kind === "drag" ? "drag" : "press", target: s.target, parent: s.parent, k: s.k, ...s.host ? { host: s.host } : {} });
5609
+ out.push(`${shownPlace}: ${w.information || !w.tier ? informationLeadText(subject, w.cause.measure, w.cause.main, w.cause.pr) : screenLeadText(w.tier, subject, w.cause.measure, w.cause.main, w.cause.pr)}${w.cause.measuredDifferently?.length ? `; ${measuredDifferentlyText(w.cause.measuredDifferently)}` : ""}`);
5610
+ }
5611
+ const screens = new Map;
5612
+ for (const p of c.notInThisRun) {
5613
+ const parts = placeParts(p.place);
5614
+ const key = parts ? componentShownName(parts.screen.name, parts.screen.file ?? "") : "";
5615
+ screens.set(key, [...screens.get(key) ?? [], p]);
5616
+ }
5617
+ for (const [screen, rows] of screens) {
5618
+ const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5619
+ out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5620
+ if (rows.length > 3)
5621
+ out.push(` ${foldedText(rows.length, "places", c.page)}`);
5622
+ if (!short || rows.length <= 3)
5623
+ out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5624
+ }
5625
+ const removed = new Map;
5626
+ for (const r of c.removed)
5627
+ removed.set(r.place, [...removed.get(r.place) ?? [], r]);
5628
+ for (const [place, rows] of removed) {
5629
+ const line = removedRowsText(rows.length, c.page);
5630
+ out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5631
+ if (rows.length > 3)
5632
+ out.push(` ${foldedText(rows.length, "components", c.page)}`);
5633
+ if (!short || rows.length <= 3)
5634
+ out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5635
+ }
5636
+ return out;
5637
+ }
5638
+ function laneContextLines(context, journey, phone, verbose = false, short = false) {
5639
+ const lines = resultContextLines(context, verbose, short);
5640
+ return lines.length ? [`${journey} on ${phone}:`, ...lines.map((line) => ` ${line}`)] : [];
5641
+ }
5642
+ var exactComponentMs = (ms) => `${ms} ms`;
5643
+ var componentPageText = (page, count) => `Component rows: ${count ? `${page.offset + 1}–${page.offset + count}` : "0"} of ${page.total} stored${page.next_offset !== null ? `; next --page ${Math.floor(page.next_offset / page.limit) + 1} (MCP component_offset ${page.next_offset})` : ""}.`;
5644
+ var omissionText = (o) => `${o.run_id}: SDK omitted ${o.sdkOmitted ?? "unknown"}; Derivation left out ${o.omitted ?? "unknown"}${o.selfMs === null ? "" : ` · ${exactComponentMs(o.selfMs)}`}. Paging leaves recorded omissions unchanged.`;
5645
+ var READ_WORDS = {
5646
+ componentTime: "component render time",
5647
+ ownTime: "own time",
5648
+ detailUnavailable: "Only render time recorded; renders, parent and library detail unavailable.",
5649
+ parent: "Placed by",
5650
+ slowest: MEASURES.commit_max_ms.label,
5651
+ runA: "Run A",
5652
+ runB: "Run B",
5653
+ notMeasured: "Not measured",
5654
+ onlyA: "Only in run A",
5655
+ onlyB: "Only in run B"
5656
+ };
5657
+ var READ_USAGE = {
5658
+ listFindings: `What got ${STATE_WORDS.worse.toLowerCase()} (${STATE_WORDS.worse} / ${STATE_WORDS.warning}) or ${STATE_WORDS.better.toLowerCase()} in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset.`,
5659
+ components: " --page N | --all (places or compare: a component page, or every stored component and place; --top-components sets the bounded page size)",
5660
+ verbose: " --verbose (include source files and raw identities in text; JSON is unchanged)",
5661
+ page: "--page must be a whole number from 1; use it only with places or compare.",
5662
+ all: "--all is for places or compare; do not combine it with --page, --offset or --short.",
5663
+ detailFlag: " --detail (places: include recorded run detail; also included by --verbose or component paging)",
5664
+ detail: "Include recorded per-run component and window detail.",
5665
+ compareTop: "--top-components must be a whole number from 1 to 200 for compare.",
5666
+ top: "--top-components must be a whole number from 0 to 20, or from 1 to 200 with --page or --all.",
5667
+ paging: "The API did not advance the component page; stopped without claiming the list is complete.",
5668
+ componentOffset: "Skip this many stored components per place.",
5669
+ componentLimit: "Component page size; the response gives stored totals and next offsets."
5670
+ };
5671
+ var componentListsText = (totals, shown) => `Stored component rows: ${shown.rows} of ${totals.rows} compared; ${shown.added} of ${totals.added} ${READ_WORDS.onlyB.toLowerCase()}; ${shown.removed} of ${totals.removed} ${READ_WORDS.onlyA.toLowerCase()}.`;
5672
+ var notMeasuredText = (side) => `${READ_WORDS.notMeasured} in run ${side.toUpperCase()}`;
5673
+
5674
+ // packages/present/dist/src/words.js
5675
+ var REASON_WORDS = {
5676
+ "no-baseline": "there are no main runs on this phone and journey yet",
5677
+ "too-few-runs": "there are too few valid runs on a side",
5678
+ "judged-measures-missing": "the judged measures are missing on a side",
5679
+ "measured-differently": "the sides were measured differently",
5680
+ "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5681
+ "facts-version-mismatch": "the sides were derived with different versions",
5682
+ "not-judged-on-simulators": "timing is not judged on simulators",
5683
+ "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5684
+ "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5685
+ };
5686
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5687
+ // packages/present/dist/src/main-history.js
5688
+ var MAIN_TIER_WORDS = {
5689
+ same: "In norm",
5690
+ better: STATE_WORDS.better,
5691
+ cant: STATE_WORDS["cant-tell"],
5692
+ slight: STATE_WORDS.warning,
5693
+ worse: STATE_WORDS.worse
5694
+ };
5695
+ var MAIN_TIER_RESULT_WORDS = {
5696
+ same: STATE_WORDS["no-change"],
5697
+ better: STATE_WORDS.better,
5698
+ cant: STATE_WORDS["cant-tell"],
5699
+ slight: STATE_WORDS.warning,
5700
+ worse: STATE_WORDS.worse
5701
+ };
5702
+ var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5703
+ function mainCantWords(reason, config) {
5704
+ if (!reason)
5705
+ return "";
5706
+ 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}` : ""}`;
5707
+ }
5708
+ var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
5709
+ function mainPlaceReasonWords(reason, config) {
5710
+ switch (reason.kind) {
5711
+ case "no_baseline":
5712
+ case "noisy_main":
5713
+ return mainCantWords(reason, config);
5714
+ case "higher":
5715
+ return "Higher than main’s earlier sessions";
5716
+ case "lower":
5717
+ return "Lower than main’s earlier sessions";
5718
+ case "missing_value":
5719
+ return REASON_WORDS["runs-unknown"];
5720
+ case "measured_differently":
5721
+ return REASON_WORDS["measured-differently"];
5722
+ case "core":
5723
+ return reason.codes.map((code) => REASON_WORDS[code]).join("; ");
5724
+ }
5725
+ }
5726
+ var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
5727
+ var SHORT_ASPECT_WORDS = {
5728
+ ...ASPECT_WORDS,
5729
+ sdk: "SDK",
5730
+ renderer: "Renderer",
5731
+ endGc: "End GC",
5732
+ causeSplit: "Cause split",
5733
+ driver: "Driver",
5734
+ driverVersion: "Driver",
5735
+ journeyVersion: "Journey"
5736
+ };
5737
+ function mainSessionCantWords(places, reason, config) {
5738
+ return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
5739
+ }
5740
+ var KIND_PLURAL = {
5741
+ screen: "screens",
5742
+ press: "taps",
5743
+ drag: "scrolls",
5744
+ launch: "launch screens",
5745
+ step: "test steps",
5746
+ background: "background work",
5747
+ other: "other places"
5748
+ };
5749
+ var listWords = (kinds) => {
5750
+ const words = kinds.map((k) => KIND_PLURAL[k] ?? "other places");
5751
+ return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
5752
+ };
5753
+ function mainCutWords(v) {
5754
+ const parts = [];
5755
+ if (v.cuts.length)
5756
+ parts.push(`Starts a new ${v.cuts.join(" and ")} baseline.`);
5757
+ if (v.partial_render) {
5758
+ const cut = listWords(v.partial_render.cut);
5759
+ parts.push(`${cut[0].toUpperCase()}${cut.slice(1)} start a new render baseline; ${listWords(v.partial_render.kept)} keep theirs.`);
5760
+ }
5761
+ return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5762
+ }
5763
+ var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5131
5764
  // apps/agent/src/summary.ts
5132
5765
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5133
5766
  function plainMetric(metric) {
@@ -5209,14 +5842,26 @@ function movedCounts(sc) {
5209
5842
  }
5210
5843
  var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5211
5844
  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) => h?.measured_differently?.length ? `Measured differently (${h.measured_differently.join(", ")}) — ${NOT_BLOCKING}` : null;
5845
+ var setupLine = (h) => {
5846
+ const parts = [
5847
+ h?.measured_differently?.length ? `Measured differently (${h.measured_differently.join(", ")}) — ${NOT_BLOCKING}` : "",
5848
+ h?.waiting_for_reprocess ? waitingWords(h.waiting_for_reprocess) : ""
5849
+ ].filter(Boolean);
5850
+ return parts.length ? parts.join("; ") : null;
5851
+ };
5213
5852
  var keyHeader = (t) => ({ "x-keepsnappy-key": t.key });
5214
5853
  async function createRunSet(t, body) {
5215
5854
  const r = await call(t.fetch ?? fetch, t.url, "/v1/run-sets", keyHeader(t), body);
5216
- return { run_set_id: String(r.run_set_id), dashboard_url: String(r.dashboard_url) };
5855
+ if (typeof r.run_set_id !== "string" || !r.run_set_id)
5856
+ throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
5857
+ return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
5217
5858
  }
5218
5859
  async function completeRunSet(t, id, verdicts) {
5219
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/complete`, keyHeader(t), { verdicts });
5860
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
5861
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
5862
+ if (!r.run_set_id)
5863
+ throw new UploadError(`POST ${path}: response has no run_set_id`);
5864
+ return r;
5220
5865
  }
5221
5866
  async function createIngestRun(t, body) {
5222
5867
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
@@ -5231,13 +5876,20 @@ async function finishIngestRun(t, runId, invalidReason, farmSession) {
5231
5876
  });
5232
5877
  }
5233
5878
  async function getRunSetRuns(t, id) {
5234
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/runs`, keyHeader(t), undefined, "GET");
5879
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/runs`;
5880
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET");
5881
+ if (!Array.isArray(r.runs))
5882
+ throw new UploadError(`GET ${path}: response has no runs`);
5883
+ return r;
5235
5884
  }
5236
5885
  var FEW_RUNS = "Few runs — result may be noisy";
5237
- var word = (s, server, h) => {
5238
- const w = server || stateWord(s ?? "inconclusive");
5239
- return h?.blocking === false && h.measured_differently?.length && (w === STATE_WORDS.worse || s === "worse") ? `${w} (${NOT_BLOCKING})` : w;
5240
- };
5886
+ var word = (s, server, blocking) => badgedWord(server || stateWord(s ?? "inconclusive"), blocking);
5887
+ var setWord = (v) => word(v?.state ?? null, v?.word, v?.blocking ?? v?.headline?.blocking);
5888
+ var scenarioWord = (sc) => word(sc.state, sc.word, sc.headline?.blocking);
5889
+ var notComputed = (s) => !s.verdicts || s.verdicts.state === null && !s.verdicts.blocking;
5890
+ var resultWord = (s) => setWord(s.verdicts);
5891
+ var UNCHECKED_GATE = "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing";
5892
+ var uncheckedGate = (s) => notComputed(s) ? UNCHECKED_GATE : null;
5241
5893
  var countsLine = (c) => countsWords(c);
5242
5894
  var noCounts = (c) => !c.worse && !c.warnings && !c.better && !c.noChange && !c.inconclusive;
5243
5895
  var change = (metric, a, b) => {
@@ -5250,7 +5902,7 @@ function formatRunSet(s, top = 5) {
5250
5902
  const lines = [];
5251
5903
  const where = `${s.branch}${s.commit_sha ? ` @ ${s.commit_sha.slice(0, 7)}` : ""}`;
5252
5904
  const v = s.verdicts;
5253
- lines.push(`KeepSnappy result for ${where}: ${word(v?.state ?? null, v?.word, v?.headline)}`);
5905
+ lines.push(`KeepSnappy result for ${where}: ${setWord(v)}`);
5254
5906
  if (setupLine(v?.headline))
5255
5907
  lines.push(` ${setupLine(v?.headline)}`);
5256
5908
  if (v?.low_data)
@@ -5264,13 +5916,14 @@ function formatRunSet(s, top = 5) {
5264
5916
  const dev = sc.device ? `${sc.device.model}${sc.device.os ? ` (${sc.device.os})` : ""}` : "no device";
5265
5917
  const what = sc.role === "comparison" ? "compared with the baseline branch" : sc.role === "history" ? "latest runs compared with earlier ones" : "not judged (no branch or flow name)";
5266
5918
  lines.push("");
5267
- lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${word(sc.state, sc.word, sc.headline)}`);
5919
+ lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${scenarioWord(sc)}`);
5268
5920
  if (setupLine(sc.headline) && (v?.scenarios.length ?? 0) > 1)
5269
5921
  lines.push(` ${setupLine(sc.headline)}`);
5270
5922
  if (sc.low_data && (v?.scenarios.length ?? 0) > 1)
5271
5923
  lines.push(` ${FEW_RUNS}`);
5272
5924
  if (sc.role !== "adhoc" && !noCounts(movedCounts(sc)))
5273
5925
  lines.push(` ${countsLine(movedCounts(sc))}`);
5926
+ lines.push(...resultContextLines(sc.context).map((line) => ` ${line}`));
5274
5927
  if (heatNote(sc))
5275
5928
  lines.push(` ${heatNote(sc)}`);
5276
5929
  const allMoved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn"));
@@ -5302,12 +5955,13 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5302
5955
  }
5303
5956
  const v = s.verdicts;
5304
5957
  const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5958
+ const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", sc.device?.model ?? "-"));
5305
5959
  const rows = v.scenarios.map((sc) => {
5306
5960
  const c = movedCounts(sc);
5307
- return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${word(sc.state, sc.word, sc.headline)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5961
+ return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${scenarioWord(sc)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5308
5962
  });
5309
5963
  return [
5310
- `### KeepSnappy: ${word(v.state, v.word, v.headline)}`,
5964
+ `### KeepSnappy: ${setWord(v)}`,
5311
5965
  "",
5312
5966
  ...setupLine(v.headline) ? [`${setupLine(v.headline)}.`, ""] : [],
5313
5967
  `\`${s.branch}\`${s.commit_sha ? ` @ \`${s.commit_sha.slice(0, 7)}\`` : ""}, ${s.runs.valid}/${s.runs.total} runs used.${v.low_data ? ` ${FEW_RUNS}.` : ""}`,
@@ -5315,6 +5969,7 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5315
5969
  `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5316
5970
  "|---|---|---|---|---|",
5317
5971
  ...rows,
5972
+ ...contextLines.length ? ["", ...contextLines] : [],
5318
5973
  ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5319
5974
  "",
5320
5975
  `[Open the dashboard](${s.dashboard_url})`,
@@ -8827,15 +9482,18 @@ ${r.stderr}` : ""}`);
8827
9482
  }
8828
9483
 
8829
9484
  // apps/agent/src/run.ts
8830
- function renameMetrics(rows, rename) {
9485
+ var matchesMetric = (prefix, metric) => metric === prefix || metric.startsWith(`${prefix}.`);
9486
+ function renameMetrics(rows, rename, warn = console.warn) {
9487
+ for (const key of Object.keys(rename)) {
9488
+ if (!rows.some((r) => [key, `step:${key}`].some((prefix) => matchesMetric(prefix, r.metric))))
9489
+ warn(`steps.rename: key "${key}" matches no metric.`);
9490
+ }
8831
9491
  const pairs = Object.entries(rename).flatMap(([a, b]) => [[a, b], [`step:${a}`, `step:${b}`]]);
8832
9492
  if (pairs.length === 0)
8833
9493
  return [...rows];
8834
9494
  return rows.map((r) => {
8835
9495
  for (const [from, to] of pairs) {
8836
- if (r.metric === from)
8837
- return { ...r, metric: to };
8838
- if (r.metric.startsWith(`${from}.`))
9496
+ if (matchesMetric(from, r.metric))
8839
9497
  return { ...r, metric: `${to}${r.metric.slice(from.length)}` };
8840
9498
  }
8841
9499
  return r;
@@ -8853,7 +9511,7 @@ async function keepsnappyRun(deps, source, opts) {
8853
9511
  ...opts.markers ? { markers: opts.markers } : {},
8854
9512
  ...opts.clockOffsetMs === undefined ? {} : { clockOffsetMs: opts.clockOffsetMs }
8855
9513
  });
8856
- const rows = renameMetrics(raw, opts.config.steps.rename);
9514
+ const rows = renameMetrics(raw, opts.config.steps.rename, deps.log);
8857
9515
  await deps.fs.writeFile(join8(opts.outDir, "metrics.csv"), toCsv(rows));
8858
9516
  const summary = summarize({ runs, outDir: opts.outDir });
8859
9517
  return { runs, rows, summary };
@@ -9147,6 +9805,14 @@ ${outcome.summary}`);
9147
9805
  ...api.fetch ? { fetch: api.fetch } : {}
9148
9806
  }).catch(infra(`results are saved in ${out}, but the upload failed`));
9149
9807
  rt.print(`uploaded ${up.uploaded} runs to ${api.url}${up.skipped.length ? ` (${up.skipped.length} skipped: ${up.skipped.map((x) => `${x.run_id} ${x.why}`).join(", ")})` : ""}`);
9808
+ if (up.uploaded === 0) {
9809
+ if (runSet)
9810
+ await completeRunSet(api, runSet.run_set_id, "on-read").catch(infra("no run was uploaded, and the run set could not be closed"));
9811
+ rt.print("Blocked: no run sent any data. Check that the build carries the SDK and that the app reaches the API.");
9812
+ if (runSet)
9813
+ await writeResult(deps, out, rt, runSet, null, git.branch, true);
9814
+ return EXIT.infra;
9815
+ }
9150
9816
  if (!runSet)
9151
9817
  return EXIT.ok;
9152
9818
  let summary = null;
@@ -9163,6 +9829,9 @@ ${formatRunSet(summary)}`);
9163
9829
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
9164
9830
  return EXIT.worse;
9165
9831
  }
9832
+ const unchecked = o["fail-on-worse"] && summary ? uncheckedGate(summary) : null;
9833
+ if (unchecked)
9834
+ rt.print(unchecked);
9166
9835
  return EXIT.ok;
9167
9836
  }
9168
9837
  async function replayFlow(config, scenario) {
@@ -9179,13 +9848,16 @@ var infra = (what) => (e) => {
9179
9848
  throw new Error(`${what}: ${e.message}`);
9180
9849
  throw e;
9181
9850
  };
9182
- async function writeResult(deps, out, rt, runSet, summary, branch) {
9851
+ async function writeResult(deps, out, rt, runSet, summary, branch, blocked = false) {
9183
9852
  const url = summary?.dashboard_url ?? runSet.dashboard_url;
9184
- await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: summary?.verdicts?.state ?? null, summary }, null, 2)}
9853
+ await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: blocked ? "blocked" : summary?.verdicts?.state ?? null, summary }, null, 2)}
9185
9854
  `);
9186
9855
  const step = rt.env.GITHUB_STEP_SUMMARY;
9187
9856
  if (step)
9188
- await deps.fs.appendFile(step, markdownRunSet(summary, { dashboardUrl: url, branch }));
9857
+ await deps.fs.appendFile(step, blocked ? `### KeepSnappy: Blocked
9858
+
9859
+ No run sent any data. [Open the dashboard](${url})
9860
+ ` : markdownRunSet(summary, { dashboardUrl: url, branch }));
9189
9861
  }
9190
9862
 
9191
9863
  // apps/agent/src/doctor.ts
@@ -9272,10 +9944,20 @@ var nodeSpawner = {
9272
9944
  let stdout = "";
9273
9945
  let stderr = "";
9274
9946
  let error;
9947
+ let timeout;
9948
+ let killTimeout;
9949
+ let closed = false;
9275
9950
  const child = spawn(cmd, [...args], {
9276
- env: { ...process.env, ...opts?.env },
9277
- ...opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}
9951
+ env: { ...process.env, ...opts?.env }
9278
9952
  });
9953
+ if (opts?.timeoutMs) {
9954
+ timeout = setTimeout(() => {
9955
+ error = `timed out after ${opts.timeoutMs}ms`;
9956
+ child.kill("SIGTERM");
9957
+ if (!closed)
9958
+ killTimeout = setTimeout(() => child.kill("SIGKILL"), opts.killAfterMs ?? 120000);
9959
+ }, opts.timeoutMs);
9960
+ }
9279
9961
  child.stdout.on("data", (d) => {
9280
9962
  stdout += d.toString();
9281
9963
  opts?.onOutput?.(d.toString());
@@ -9286,8 +9968,11 @@ var nodeSpawner = {
9286
9968
  });
9287
9969
  child.on("error", (e) => error = e.message);
9288
9970
  child.on("close", (code, signal) => {
9289
- if (signal && !error)
9290
- error = `killed by ${signal}`;
9971
+ closed = true;
9972
+ clearTimeout(timeout);
9973
+ clearTimeout(killTimeout);
9974
+ if (signal)
9975
+ error = error ? `${error}; killed by ${signal}` : `killed by ${signal}`;
9291
9976
  resolve({ code, stdout, stderr, ...error ? { error } : {} });
9292
9977
  });
9293
9978
  })
@@ -9330,7 +10015,7 @@ async function aa(deps, outDir, opts = {}) {
9330
10015
  }
9331
10016
 
9332
10017
  // apps/agent/src/farm.ts
9333
- import { dirname as dirname3, join as join12, resolve as resolve5 } from "node:path";
10018
+ import { dirname as dirname3, join as join13, resolve as resolve5 } from "node:path";
9334
10019
 
9335
10020
  // packages/appium-driver/dist/src/links.js
9336
10021
  var ALERT_POLL_MS = 250;
@@ -9628,18 +10313,13 @@ class WdError extends Error {
9628
10313
  this.wdError = wdError;
9629
10314
  }
9630
10315
  }
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
10316
  // packages/appium-driver/dist/src/version.js
9637
10317
  import { createHash } from "node:crypto";
9638
10318
  import { existsSync, readFileSync, statSync } from "node:fs";
9639
10319
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
9640
10320
  import { fileURLToPath } from "node:url";
9641
10321
  function driverVersion() {
9642
- const built = "0.1.0-alpha.15";
10322
+ const built = "0.1.0-alpha.17";
9643
10323
  if (built)
9644
10324
  return built;
9645
10325
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -9688,8 +10368,91 @@ function journeyVersion(modulePath) {
9688
10368
  hash.update(key).update("\x00").update(text).update("\x00");
9689
10369
  return hash.digest("hex").slice(0, 12);
9690
10370
  }
10371
+
10372
+ // packages/appium-driver/dist/src/session.js
10373
+ var SDK_FLUSH_WORST_CASE_MS = 315000;
10374
+ var FLUSH_MARGIN_MS = 30000;
10375
+ var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10376
+ var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
10377
+ // apps/agent/src/device-model.ts
10378
+ import { tmpdir } from "node:os";
10379
+ import { join as join12 } from "node:path";
10380
+ function parseDevicectlList(json) {
10381
+ let data;
10382
+ try {
10383
+ data = JSON.parse(json);
10384
+ } catch {
10385
+ return [];
10386
+ }
10387
+ const devices = Array.isArray(data.result?.devices) ? data.result.devices : [];
10388
+ return devices.flatMap((d) => {
10389
+ const hw = d.hardwareProperties ?? {};
10390
+ const conn = d.connectionProperties ?? {};
10391
+ if (typeof hw.productType !== "string" || !hw.productType)
10392
+ return [];
10393
+ const text = (v) => typeof v === "string" ? v : "";
10394
+ return [{ productType: hw.productType, udid: text(hw.udid), identifier: text(d.identifier), serial: text(hw.serialNumber), connected: conn.tunnelState === "connected" }];
10395
+ });
10396
+ }
10397
+ var parseSimulators = (json) => {
10398
+ try {
10399
+ const data = JSON.parse(json);
10400
+ return Object.values(data.devices ?? {}).flat();
10401
+ } catch {
10402
+ return [];
10403
+ }
10404
+ };
10405
+ var withSimulatorWord = (name) => /simulator/i.test(name) ? name : `${name} Simulator`;
10406
+ var simulatorModel = (s) => withSimulatorWord(s.name);
10407
+ async function androidModel(deps, id) {
10408
+ const r = await deps.spawner.run("adb", [...id ? ["-s", id] : [], "shell", "getprop", "ro.product.model"]);
10409
+ const model = r.code === 0 ? (r.stdout.split(`
10410
+ `)[0] ?? "").trim() : "";
10411
+ return model || null;
10412
+ }
10413
+ async function simulators(deps, id) {
10414
+ const r = await deps.spawner.run("xcrun", ["simctl", "list", "devices", ...id ? [] : ["booted"], "-j"]);
10415
+ if (r.code !== 0)
10416
+ return [];
10417
+ return parseSimulators(r.stdout).filter((s) => id ? s.udid === id : s.state === "Booted");
10418
+ }
10419
+ async function phones(deps) {
10420
+ const out = join12(tmpdir(), `keepsnappy-devicectl-${deps.clock.now()}.json`);
10421
+ try {
10422
+ const r = await deps.spawner.run("xcrun", ["devicectl", "list", "devices", "--quiet", "--json-output", out]);
10423
+ const text = r.code === 0 ? await deps.fs.readFile(out).catch(() => "") : "";
10424
+ return parseDevicectlList(text);
10425
+ } finally {
10426
+ await deps.spawner.run("rm", ["-f", out]);
10427
+ }
10428
+ }
10429
+ async function iosModel(deps, id) {
10430
+ const sims = await simulators(deps, id);
10431
+ if (id) {
10432
+ if (sims.length === 1)
10433
+ return simulatorModel(sims[0]);
10434
+ const found = (await phones(deps)).filter((p) => [p.udid, p.identifier, p.serial].some((x) => x.toLowerCase() === id.toLowerCase()));
10435
+ return found.length === 1 ? found[0].productType : null;
10436
+ }
10437
+ const connected = (await phones(deps)).filter((p) => p.connected);
10438
+ if (sims.length + connected.length !== 1)
10439
+ return null;
10440
+ return sims[0] ? simulatorModel(sims[0]) : connected[0].productType;
10441
+ }
10442
+ async function detectDeviceModel(deps, q) {
10443
+ if (q.platform === "android")
10444
+ return androidModel(deps, q.deviceId);
10445
+ if (q.platform === "ios")
10446
+ return iosModel(deps, q.deviceId);
10447
+ return null;
10448
+ }
10449
+ async function detectSimulator(deps, udid) {
10450
+ const sims = await simulators(deps, udid);
10451
+ return sims.length === 1 ? simulatorModel(sims[0]) : null;
10452
+ }
10453
+
9691
10454
  // apps/agent/src/slack.ts
9692
- var COLOR = { error: "#E01E5A", warn: "#ECB22E", better: "#2EB67D", none: "#9AA0A6" };
10455
+ var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
9693
10456
  var MAX_ROWS = 3;
9694
10457
  var NAME_W = 30;
9695
10458
  var COLUMN_GAP = 1;
@@ -9719,7 +10482,7 @@ var isBetter = (i) => i.state === "better";
9719
10482
  var differs = (sc) => sc?.headline?.measured_differently ?? [];
9720
10483
  var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
9721
10484
  var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
9722
- var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : "\uD83D\uDFE0";
10485
+ var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : i.grade !== "warn" ? "⚪" : "\uD83D\uDFE1";
9723
10486
  var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
9724
10487
  var right = (s, w) => s.padStart(w);
9725
10488
  var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
@@ -9780,26 +10543,29 @@ function slackMessage(s, ctx) {
9780
10543
  const better = items.filter(isBetter).sort(bySize);
9781
10544
  const commit = commitLine(ctx);
9782
10545
  const heated = v.scenarios.filter((sc) => heatNote(sc));
10546
+ const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", phone(sc))).map(context);
9783
10547
  const heat = heated.map((sc) => context(heatNote(sc)));
9784
10548
  const errorRows = worse.filter((i) => i.grade !== "warn");
9785
10549
  const warningRows = worse.filter((i) => i.grade === "warn");
9786
10550
  const errors = placeCount(errorRows);
9787
10551
  const warns = placeCount(worse) - errors;
10552
+ const red = errorRows.some((i) => isRed(i, scenarioOf(i)));
10553
+ const notBlocking = errors > 0 && !red;
10554
+ const worseMark = red ? "\uD83D\uDD34" : warns > 0 ? "\uD83D\uDFE1" : "⚪";
10555
+ const worseColor = red ? COLOR.error : warns > 0 ? COLOR.warn : COLOR.none;
9788
10556
  if (isPr) {
9789
10557
  if (!worse.length) {
9790
10558
  if (!heat.length)
9791
10559
  return null;
9792
- const head = `⚪ ${ctx.branch}: ${INFO_WORDS.heat}`;
9793
- const blocks = [section(`*${head}*`), ...heat, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0].dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10560
+ const head = `⚪ ${ctx.branch}: ${heat.length ? INFO_WORDS.heat : v.word ?? STATE_WORDS["no-change"]}`;
10561
+ const blocks = [section(`*${head}*`), ...heat, ...contextLines, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0]?.dashboard_url ?? s.dashboard_url}|View the comparison>`)];
9794
10562
  return { text: head, attachments: [{ color: COLOR.none, blocks }] };
9795
10563
  }
9796
- const red = worse.some((i) => isRed(i, scenarioOf(i)));
9797
10564
  const graded = worse.some((i) => i.grade);
9798
- const notBlocking = errors > 0 && !red;
9799
10565
  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 = `${red ? "\uD83D\uDD34" : "\uD83D\uDFE0"} ${ctx.branch}: ${counts}`;
9802
- const blocks = [section(`*${head}*`)];
10566
+ 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})` : ""}`;
10567
+ const head = `${worseMark} ${ctx.branch}: ${counts}`;
10568
+ const blocks = [section(`*${head}*`), ...contextLines];
9803
10569
  const groups = new Map;
9804
10570
  for (const i of worse)
9805
10571
  groups.set(scenarioOf(i), [...groups.get(scenarioOf(i)) ?? [], i]);
@@ -9814,7 +10580,8 @@ function slackMessage(s, ctx) {
9814
10580
  const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
9815
10581
  const note = differs(sc).length ? `
9816
10582
  _Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
9817
- blocks.push(section(`*${sc?.scenario ?? "journey"}* · ${phone(sc)}${runs}${note}
10583
+ const nonblocking = !scenarioBlocks(sc) && rows.some((i) => i.grade !== "warn") && !differs(sc).length ? ` · ${NOT_BLOCKING}` : "";
10584
+ blocks.push(section(`*${sc?.scenario ?? "journey"}*${nonblocking} · ${phone(sc)}${runs}${note}
9818
10585
  ${prTable(rows, sc)}`));
9819
10586
  }
9820
10587
  const more = worse.length - shown;
@@ -9822,18 +10589,18 @@ ${prTable(rows, sc)}`));
9822
10589
  if (commit)
9823
10590
  blocks.push(context(commit));
9824
10591
  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: red ? COLOR.error : COLOR.warn, blocks }] };
10592
+ return { text: head, attachments: [{ color: worseColor, blocks }] };
9826
10593
  }
9827
10594
  const improved = better.length > 0 || v.state === "better";
9828
10595
  if (!worse.length && !improved && !ctx.always && !heat.length)
9829
10596
  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);
10597
+ 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
10598
  const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
9832
10599
  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 ? worse.some((i) => isRed(i, scenarioOf(i))) ? "\uD83D\uDD34" : "\uD83D\uDFE0" : improved ? "\uD83D\uDFE2" : "⚪"} ${status}`;
10600
+ const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
9834
10601
  const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
9835
10602
  const blocks = [section(`*${head}*
9836
- ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
10603
+ ${journeys} journey${journeys === 1 ? "" : "s"} checked`), ...contextLines];
9837
10604
  let shown = 0;
9838
10605
  for (const [title, list] of [[STATE_WORDS.worse, errorRows], [STATE_WORDS.warning, warningRows], [STATE_WORDS.better, better]]) {
9839
10606
  for (const i of list) {
@@ -9841,7 +10608,7 @@ ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
9841
10608
  break;
9842
10609
  shown++;
9843
10610
  const sc = scenarioOf(i);
9844
- blocks.push(section(`*${title}* · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
10611
+ blocks.push(section(`*${title}*${i.grade !== "warn" && isWorse(i) && !scenarioBlocks(sc) ? ` (${NOT_BLOCKING})` : ""} · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
9845
10612
  ${mainCard(i)}`));
9846
10613
  }
9847
10614
  }
@@ -9850,7 +10617,7 @@ ${mainCard(i)}`));
9850
10617
  if (commit)
9851
10618
  blocks.push(context(commit));
9852
10619
  blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
9853
- const color = worse.length ? worse.some((i) => isRed(i, scenarioOf(i))) ? COLOR.error : COLOR.warn : improved ? COLOR.better : COLOR.none;
10620
+ const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
9854
10621
  return { text: head, attachments: [{ color, blocks }] };
9855
10622
  }
9856
10623
  async function postToSlack(url, payload, fetchImpl = fetch) {
@@ -9869,17 +10636,23 @@ var LINKS_USAGE = `keepsnappy activation-link --scheme <app://> --token <run tok
9869
10636
  flush-link --scheme <app://>
9870
10637
  gc-link --scheme <app://> [--n 1]`;
9871
10638
  function linksCommand(cmd, argv, rt) {
9872
- const { values: o } = parseArgs({
9873
- args: [...argv],
9874
- options: {
9875
- scheme: { type: "string" },
9876
- token: { type: "string" },
9877
- n: { type: "string" },
9878
- "no-diag": { type: "boolean" },
9879
- "no-profile": { type: "boolean" },
9880
- set: { type: "string", multiple: true }
10639
+ const { values: o } = (() => {
10640
+ try {
10641
+ return parseArgs({
10642
+ args: [...argv],
10643
+ options: {
10644
+ scheme: { type: "string" },
10645
+ token: { type: "string" },
10646
+ n: { type: "string" },
10647
+ "no-diag": { type: "boolean" },
10648
+ "no-profile": { type: "boolean" },
10649
+ set: { type: "string", multiple: true }
10650
+ }
10651
+ });
10652
+ } catch (e) {
10653
+ throw new UsageError([e.message, LINKS_USAGE]);
9881
10654
  }
9882
- });
10655
+ })();
9883
10656
  if (!o.scheme)
9884
10657
  throw new ConfigError([`--scheme is required (the app's URL scheme, e.g. myapp://)`]);
9885
10658
  if (cmd === "flush-link") {
@@ -9997,9 +10770,20 @@ async function farmOpen(deps, f, rt) {
9997
10770
  const git = gitContext({ ...f.branch ? { branch: f.branch } : {}, ...f.commit ? { commit: f.commit } : {} }, rt.env);
9998
10771
  if (!git.branch)
9999
10772
  problems.push("--branch is required outside GitHub Actions");
10000
- const model = f["device-model"];
10773
+ let provider = f["device-provider"] ?? "browserstack";
10774
+ let model = f["device-model"]?.trim();
10775
+ const simulator = f.platform === "ios" && provider !== "browserstack" && f["device-id"]?.trim() ? await detectSimulator(deps, f["device-id"].trim()) : null;
10776
+ if (simulator) {
10777
+ provider = "simulator";
10778
+ model = model ? withSimulatorWord(model) : simulator;
10779
+ }
10780
+ if (!model && ["local", "usb", "customer", "simulator", "emulator"].includes(provider)) {
10781
+ model = await detectDeviceModel(deps, { platform: f.platform, deviceId: f["device-id"]?.trim() }) ?? undefined;
10782
+ if (model)
10783
+ rt.print(`device model read from the device: ${model}`);
10784
+ }
10001
10785
  if (!model)
10002
- problems.push('--device-model is required: the journey history is keyed by the run set’s device (e.g. "iPhone 15 Pro")');
10786
+ 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
10787
  if (f.platform !== undefined && !["ios", "android", "web"].includes(f.platform))
10004
10788
  problems.push("--platform must be ios, android or web");
10005
10789
  const journeys = (f.journeys ?? "").split(",").map((j) => j.trim()).filter(Boolean);
@@ -10029,7 +10813,7 @@ async function farmOpen(deps, f, rt) {
10029
10813
  }
10030
10814
  const api = apiTarget(url, f, rt);
10031
10815
  const now = rt.now ?? Date.now;
10032
- const device = { model, os: f["device-os"] ?? null, provider: f["device-provider"] ?? "browserstack" };
10816
+ const device = { model, os: f["device-os"] ?? null, provider };
10033
10817
  const journeyInfo = await farmJourneys(deps, f, rt, journeys);
10034
10818
  const withInstrument = { ...f, ...instrumentFlags(f) };
10035
10819
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
@@ -10287,7 +11071,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
10287
11071
  await deps.fs.writeFile(statePath, `${JSON.stringify(state2, null, 2)}
10288
11072
  `);
10289
11073
  await deps.fs.mkdir(out);
10290
- await deps.fs.writeFile(join12(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)}
11074
+ 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
11075
  `);
10292
11076
  await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
10293
11077
  const step = rt.env.GITHUB_STEP_SUMMARY;
@@ -10314,9 +11098,9 @@ ${tail.join(" ")}
10314
11098
  ...blocked ? { blocked: { reason: `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.` } } : {},
10315
11099
  always: envValue(rt.env, "SLACK_ALWAYS") === "1"
10316
11100
  });
10317
- if (!msg)
10318
- rt.print(f.wait ? `Slack: nothing to report (${STATE_WORDS["no-change"].toLowerCase()})` : "Slack: skipped, the message needs --wait");
10319
- else {
11101
+ if (!msg) {
11102
+ rt.print(!f.wait ? "Slack: skipped, the message needs --wait" : notComputed(summary) ? "Slack: nothing to report, the verdict is not computed yet" : `Slack: nothing to report (${resultWord(summary).toLowerCase()})`);
11103
+ } else {
10320
11104
  const r = await postToSlack(hook, msg, rt.fetch ?? fetch);
10321
11105
  rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
10322
11106
  }
@@ -10327,17 +11111,20 @@ ${tail.join(" ")}
10327
11111
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
10328
11112
  return EXIT.worse;
10329
11113
  }
11114
+ const unchecked = f["fail-on-worse"] ? uncheckedGate(summary) : null;
11115
+ if (unchecked)
11116
+ rt.print(unchecked);
10330
11117
  return EXIT.ok;
10331
11118
  }
10332
11119
 
10333
11120
  // apps/agent/src/farm-run.ts
10334
11121
  import { existsSync as existsSync2 } from "node:fs";
10335
11122
  import { createRequire } from "node:module";
10336
- import { dirname as dirname4, join as join15, resolve as resolve6 } from "node:path";
11123
+ import { dirname as dirname4, join as join16, resolve as resolve6 } from "node:path";
10337
11124
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10338
11125
 
10339
11126
  // apps/agent/src/app-source.ts
10340
- import { join as join13 } from "node:path";
11127
+ import { join as join14 } from "node:path";
10341
11128
  var EAS_CLI_VERSION = "24.6.0";
10342
11129
  var isEas = (spec) => spec.startsWith("eas:");
10343
11130
 
@@ -10349,7 +11136,7 @@ class DefaultAppResolver {
10349
11136
  this.env = env;
10350
11137
  }
10351
11138
  async resolve(spec, o) {
10352
- if (spec.startsWith("bs://"))
11139
+ if (spec.startsWith("bs://") || spec.startsWith("custom_id:"))
10353
11140
  return { app: spec, commit: null };
10354
11141
  if (!isEas(spec)) {
10355
11142
  if (!await this.deps.fs.exists(spec))
@@ -10373,7 +11160,7 @@ class DefaultAppResolver {
10373
11160
  const url = build.artifacts?.buildUrl ?? build.artifacts?.applicationArchiveUrl;
10374
11161
  if (!url)
10375
11162
  throw new Error(`EAS build ${id} has no downloadable artifact (is it finished?)`);
10376
- const file = join13(o.outDir, `app-${id.slice(0, 8)}${build.platform === "ANDROID" ? ".apk" : ".ipa"}`);
11163
+ const file = join14(o.outDir, `app-${id.slice(0, 8)}${build.platform === "ANDROID" ? ".apk" : ".ipa"}`);
10377
11164
  await this.deps.fs.mkdir(o.outDir);
10378
11165
  const dl = await this.deps.spawner.run("curl", ["-fsSL", "-o", file, url]);
10379
11166
  if (dl.code !== 0)
@@ -10384,7 +11171,7 @@ class DefaultAppResolver {
10384
11171
 
10385
11172
  // apps/agent/src/browserstack.ts
10386
11173
  import { randomUUID } from "node:crypto";
10387
- import { basename as basename2, join as join14 } from "node:path";
11174
+ import { basename as basename2, join as join15 } from "node:path";
10388
11175
  import { readFile as readFile3 } from "node:fs/promises";
10389
11176
  var BASE = "https://api-cloud.browserstack.com/app-automate/maestro/v2";
10390
11177
  var TERMINAL = new Set(["passed", "failed", "error", "timedout", "skipped", "stopped"]);
@@ -10455,7 +11242,7 @@ class BrowserStack {
10455
11242
  }
10456
11243
  }
10457
11244
  async uploadApp(app, customId) {
10458
- if (app.startsWith("bs://"))
11245
+ if (app.startsWith("bs://") || app.startsWith("custom_id:"))
10459
11246
  return app;
10460
11247
  const form = new FormData;
10461
11248
  form.append("file", new Blob([await this.bytes(app)]), basename2(app));
@@ -10468,7 +11255,7 @@ class BrowserStack {
10468
11255
  }
10469
11256
  async runMaestro(b) {
10470
11257
  const appUrl = await this.uploadApp(b.app, b.customId);
10471
- const work = join14(this.o.tmpDir ?? "/tmp", `keepsnappy-suite-${(this.o.uniqueId ?? randomUUID)()}`);
11258
+ const work = join15(this.o.tmpDir ?? "/tmp", `keepsnappy-suite-${(this.o.uniqueId ?? randomUUID)()}`);
10472
11259
  let suiteUrl;
10473
11260
  try {
10474
11261
  const zip = await this.zipSuite(b, work);
@@ -10501,17 +11288,17 @@ class BrowserStack {
10501
11288
  return this.poll(buildId, b);
10502
11289
  }
10503
11290
  async zipSuite(b, work) {
10504
- const zipPath = join14(work, "suite.zip");
10505
- const stage = join14(work, "stage");
11291
+ const zipPath = join15(work, "suite.zip");
11292
+ const stage = join15(work, "stage");
10506
11293
  await this.deps.fs.mkdir(stage);
10507
11294
  const cp = await this.deps.spawner.run("cp", ["-R", b.suiteDir.replace(/\/$/, ""), stage]);
10508
11295
  if (cp.code !== 0)
10509
11296
  throw new FarmApiError(`cannot copy the suite ${b.suiteDir}: ${cp.stderr || cp.error}`);
10510
11297
  const folder = basename2(b.suiteDir.replace(/\/$/, ""));
10511
- if (!await this.deps.fs.exists(join14(stage, folder, "config.yaml")))
11298
+ if (!await this.deps.fs.exists(join15(stage, folder, "config.yaml")))
10512
11299
  throw new FarmApiError(`${b.suiteDir} has no config.yaml: is that the Maestro workspace?`);
10513
- const target = join14(stage, folder, b.tokensPath);
10514
- await this.deps.fs.mkdir(join14(target, ".."));
11300
+ const target = join15(stage, folder, b.tokensPath);
11301
+ await this.deps.fs.mkdir(join15(target, ".."));
10515
11302
  await this.deps.fs.writeFile(target, await this.deps.fs.readFile(b.tokensJs));
10516
11303
  const include = SUITE_EXTENSIONS.flatMap((e) => ["-i", `${folder}/*${e}`, "-i", `${folder}/**/*${e}`]);
10517
11304
  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 +11357,9 @@ class BrowserStackLocal {
10570
11357
  const url = TUNNEL_ZIP[this.o.platform ?? process.platform];
10571
11358
  if (!url)
10572
11359
  throw new FarmApiError(`no BrowserStackLocal binary known for ${this.o.platform ?? process.platform}`);
10573
- const dir = join14(this.o.dir, identifier);
10574
- const bin = join14(dir, "BrowserStackLocal");
10575
- const zip = join14(dir, "bs-local.zip");
11360
+ const dir = join15(this.o.dir, identifier);
11361
+ const bin = join15(dir, "BrowserStackLocal");
11362
+ const zip = join15(dir, "bs-local.zip");
10576
11363
  await this.deps.fs.mkdir(dir);
10577
11364
  for (const [cmd, args] of [
10578
11365
  ["curl", ["-fsSL", "-o", zip, url]],
@@ -10606,12 +11393,12 @@ ${r.stderr}`;
10606
11393
  // apps/agent/src/farm-run.ts
10607
11394
  function appiumDriverEntry(o = {}) {
10608
11395
  const exists = o.exists ?? existsSync2;
10609
- const bundled = join15(o.selfDir ?? dirname4(fileURLToPath2(import.meta.url)), "appium-driver.js");
11396
+ const bundled = join16(o.selfDir ?? dirname4(fileURLToPath2(import.meta.url)), "appium-driver.js");
10610
11397
  if (exists(bundled))
10611
11398
  return bundled;
10612
11399
  const pkgDir = dirname4(o.packageJson ? o.packageJson() : createRequire(import.meta.url).resolve("@keepsnappy/appium-driver/package.json"));
10613
- const built = join15(pkgDir, "dist", "src", "cli.js");
10614
- return exists(built) ? built : join15(pkgDir, "src", "cli.ts");
11400
+ const built = join16(pkgDir, "dist", "src", "cli.js");
11401
+ return exists(built) ? built : join16(pkgDir, "src", "cli.ts");
10615
11402
  }
10616
11403
  var driverRuntime = (entry) => entry.endsWith(".ts") ? process.execPath : "node";
10617
11404
  function nodeFarmRunPorts(deps, rt) {
@@ -10620,7 +11407,7 @@ function nodeFarmRunPorts(deps, rt) {
10620
11407
  if (!user || !key)
10621
11408
  throw new ConfigError(["BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY must be set"]);
10622
11409
  const tmp = rt.env.RUNNER_TEMP ?? rt.env.TMPDIR ?? "/tmp";
10623
- const dir = join15(tmp, "keepsnappy-bs-local");
11410
+ const dir = join16(tmp, "keepsnappy-bs-local");
10624
11411
  return {
10625
11412
  apps: new DefaultAppResolver(deps, rt.env),
10626
11413
  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 +11423,7 @@ var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
10636
11423
  function planFarmRun(f) {
10637
11424
  const problems = [];
10638
11425
  if (!f.app)
10639
- problems.push("--app <eas:<build id>|path to .ipa/.apk|bs://id> is required");
11426
+ problems.push("--app <eas:<build id>|path to .ipa/.apk|bs://id|custom_id:id> is required");
10640
11427
  const platform = f.platform ?? "ios";
10641
11428
  if (platform !== "ios" && platform !== "android")
10642
11429
  problems.push("--platform must be ios or android");
@@ -10682,14 +11469,15 @@ function planFarmRun(f) {
10682
11469
  problems.push("--driver-script and --journey-module are two ways to run Appium; pass one");
10683
11470
  } else if (!f["driver-script"] && !f["journey-module"]) {
10684
11471
  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
11472
  }
10688
11473
  if (f["run-id"] !== undefined && !RUN_ID.test(f["run-id"]))
10689
11474
  problems.push("--run-id: letters, digits, . _ - only (it names the tunnel and its folder)");
10690
11475
  if (f["fail-on-worse"] && !f.wait)
10691
11476
  problems.push("--fail-on-worse needs --wait");
10692
11477
  const timeoutMinutes = Number(f["timeout-minutes"] ?? 80);
11478
+ const killAfterSeconds = Number(f["kill-after-seconds"] ?? 120);
11479
+ if (!Number.isFinite(killAfterSeconds) || killAfterSeconds <= 0)
11480
+ problems.push("--kill-after-seconds must be finite and > 0");
10693
11481
  const pollSeconds = Number(f["poll-seconds"] ?? 20);
10694
11482
  if (!(timeoutMinutes > 0))
10695
11483
  problems.push("--timeout-minutes must be > 0");
@@ -10709,6 +11497,7 @@ function planFarmRun(f) {
10709
11497
  driverArgs: f["driver-arg"] ?? [],
10710
11498
  out: resolve6(f.out ?? "farm-run"),
10711
11499
  timeoutMinutes,
11500
+ killAfterSeconds,
10712
11501
  pollSeconds
10713
11502
  };
10714
11503
  }
@@ -10732,8 +11521,8 @@ function printPlan(p, f, rt) {
10732
11521
  const line = (l) => rt.print(redact(l, secrets));
10733
11522
  line("keepsnappy farm run: dry run, no request is made and nothing is written");
10734
11523
  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://") ? "bs:// passes through" : `${p.app} (local file)`}`);
10736
- line(` 3. farm open -> ${join15(p.out, "keepsnappy-tokens.js")}, ${join15(p.out, "keepsnappy-farm.json")} (POST /v1/run-sets, then POST /v1/runs x ${p.journeys.length * p.repeat})`);
11524
+ 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)`}`);
11525
+ 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
11526
  line(` 4. tunnel: ${p.ports.length ? `BrowserStackLocal for bs-local.com ports ${p.ports.join(", ")}, identifier = run id` : "none (no --tunnel-for)"}`);
10738
11527
  if (p.driver === "maestro") {
10739
11528
  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,9 +11531,9 @@ function printPlan(p, f, rt) {
10742
11531
  }
10743
11532
  for (const [k, v] of p.env)
10744
11533
  line(` env ${k}=${substitute(v, {})}`);
10745
- line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join15(p.out, `browserstack.${p.platform}.json`)}`);
11534
+ line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
10746
11535
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
10747
- line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse");
11536
+ line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)");
10748
11537
  }
10749
11538
  var ANNOTATION = /^::(error|warning|notice)( [^:]*)?::/;
10750
11539
  async function runDriver2(deps, rt, f, p, ports, ctx) {
@@ -10766,13 +11555,13 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10766
11555
  timeoutMinutes: p.timeoutMinutes,
10767
11556
  pollSeconds: p.pollSeconds,
10768
11557
  onStarted: async (buildId) => {
10769
- await deps.fs.writeFile(join15(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)}
11558
+ 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
11559
  `);
10771
11560
  rt.print(`build ${buildId} started: https://app-automate.browserstack.com/dashboard/v2/builds/${buildId}`);
10772
11561
  },
10773
11562
  onStatus: (buildId, status) => rt.print(`build ${buildId}: ${status}`)
10774
11563
  });
10775
- await deps.fs.writeFile(join15(ctx.out, `browserstack.${p.platform}.json`), `${redact(JSON.stringify(detail.detail, null, 2), secretsOf(p, f, rt.env))}
11564
+ await deps.fs.writeFile(join16(ctx.out, `browserstack.${p.platform}.json`), `${redact(JSON.stringify(detail.detail, null, 2), secretsOf(p, f, rt.env))}
10776
11565
  `);
10777
11566
  return detail.status === "passed" ? { ok: true, message: null } : { ok: false, message: `the device build ${detail.buildId} ended ${detail.status}` };
10778
11567
  }
@@ -10809,6 +11598,7 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10809
11598
  const r = await deps.spawner.run(f["driver-script"] ? "node" : driverRuntime(entry), args, {
10810
11599
  env,
10811
11600
  timeoutMs: p.timeoutMinutes * 60000,
11601
+ killAfterMs: p.killAfterSeconds * 1000,
10812
11602
  onOutput: (chunk) => {
10813
11603
  streamed = true;
10814
11604
  const lines = (pending + chunk).split(`
@@ -10823,11 +11613,11 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10823
11613
  for (const l of `${r.stdout}${r.stderr}`.split(`
10824
11614
  `))
10825
11615
  emit(l);
10826
- return r.code === 0 ? { ok: true, message: null } : { ok: false, message: `the driver script exited ${r.code ?? r.error ?? "abnormally"}` };
11616
+ return r.code === 0 && !r.error ? { ok: true, message: null } : { ok: false, message: `the driver script exited ${r.error ?? r.code ?? "abnormally"}` };
10827
11617
  }
10828
11618
  async function farmRun(deps, f, rt, ports) {
10829
11619
  const p = planFarmRun(f);
10830
- const statePath = join15(p.out, "keepsnappy-farm.json");
11620
+ const statePath = join16(p.out, "keepsnappy-farm.json");
10831
11621
  if (await deps.fs.exists(statePath))
10832
11622
  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
11623
  if (f["dry-run"]) {
@@ -10835,7 +11625,7 @@ async function farmRun(deps, f, rt, ports) {
10835
11625
  return EXIT.ok;
10836
11626
  }
10837
11627
  const farm = ports ?? nodeFarmRunPorts(deps, rt);
10838
- const tokensJs = join15(p.out, "keepsnappy-tokens.js");
11628
+ const tokensJs = join16(p.out, "keepsnappy-tokens.js");
10839
11629
  await deps.fs.mkdir(p.out);
10840
11630
  const resolved = await farm.apps.resolve(p.app, { outDir: p.out });
10841
11631
  const commit = f.commit ?? resolved.commit ?? undefined;
@@ -10866,9 +11656,9 @@ async function farmRun(deps, f, rt, ports) {
10866
11656
  const runId = f["run-id"] ?? `keepsnappy-${p.platform}-${state.run_set_id.slice(0, 8)}`;
10867
11657
  const ctx = { runId, out: p.out, tokensJs, runSetId: state.run_set_id, app: resolved.app };
10868
11658
  try {
10869
- await deps.spawner.run("rm", ["-f", join15(p.out, `browserstack.${p.platform}.json`)]);
11659
+ await deps.spawner.run("rm", ["-f", join16(p.out, `browserstack.${p.platform}.json`)]);
10870
11660
  if (p.ports.length) {
10871
- tunnel = await farm.tunnel.open({ identifier: runId, ports: p.ports, logFile: join15(p.out, "browserstack-local.log") });
11661
+ tunnel = await farm.tunnel.open({ identifier: runId, ports: p.ports, logFile: join16(p.out, "browserstack-local.log") });
10872
11662
  rt.print(`tunnel ${runId}: bs-local.com ports ${p.ports.join(", ")}`);
10873
11663
  }
10874
11664
  driver = await runDriver2(deps, rt, f, p, farm, { ...ctx, tunnelId: tunnel?.identifier ?? null });
@@ -10879,7 +11669,7 @@ async function farmRun(deps, f, rt, ports) {
10879
11669
  rt.print(`device run failed: ${driver.message}; closing the run set with what arrived`);
10880
11670
  closeCode = await farmClose(deps, {
10881
11671
  state: statePath,
10882
- "bs-build": join15(p.out, `browserstack.${p.platform}.json`),
11672
+ "bs-build": join16(p.out, `browserstack.${p.platform}.json`),
10883
11673
  out: p.out,
10884
11674
  ...f.wait ? { wait: true } : {},
10885
11675
  ...f["fail-on-worse"] ? { "fail-on-worse": true } : {},
@@ -10900,13 +11690,82 @@ async function farmRun(deps, f, rt, ports) {
10900
11690
  }
10901
11691
 
10902
11692
  // 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";
11693
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
11694
+ import { access, readFile as readFile4, readdir as readdir2, rename, rm, writeFile as writeFile2 } from "node:fs/promises";
11695
+ import { basename as basename3, join as join17, resolve as resolve7 } from "node:path";
10906
11696
  import { parseArgs as parseArgs2 } from "node:util";
10907
11697
  import { gzipSync } from "node:zlib";
10908
11698
  var MAX_COMPLETE_ROUNDS = 20;
10909
11699
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
11700
+ async function prepareComponents(projectRoot, options = {}) {
11701
+ const root = resolve7(projectRoot);
11702
+ const directory = join17(root, ".keepsnappy", "components");
11703
+ const configurations = await readdir2(directory, { withFileTypes: true }).catch((error) => {
11704
+ if (error.code === "ENOENT")
11705
+ return [];
11706
+ throw error;
11707
+ });
11708
+ const candidates = [];
11709
+ for (const entry of configurations) {
11710
+ if (!entry.isDirectory() || !/^[a-zA-Z0-9-]+$/.test(entry.name))
11711
+ continue;
11712
+ try {
11713
+ await access(join17(directory, entry.name, ".config.json"));
11714
+ candidates.push(entry.name);
11715
+ } catch (error) {
11716
+ if (error.code !== "ENOENT")
11717
+ throw error;
11718
+ }
11719
+ }
11720
+ candidates.sort();
11721
+ if (candidates.length === 0)
11722
+ return null;
11723
+ const selectedConfiguration = options.configuration ?? (candidates.length === 1 ? candidates[0] : undefined);
11724
+ if (!selectedConfiguration || !candidates.includes(selectedConfiguration)) {
11725
+ throw new ConfigError([`choose a component configuration with --components-config (${candidates.join(", ")})`]);
11726
+ }
11727
+ const selected = join17(directory, selectedConfiguration);
11728
+ const config = JSON.parse(await readFile4(join17(selected, ".config.json"), "utf8"));
11729
+ if (!isComponentConfiguration(config) || config.directory !== selectedConfiguration) {
11730
+ throw new ConfigError(["invalid KeepSnappy component fragment configuration"]);
11731
+ }
11732
+ const files = (await readdir2(selected)).filter((file) => file.endsWith(".json") && !file.startsWith(".")).sort();
11733
+ const components = [];
11734
+ const hashes = new Map;
11735
+ let matched = 0;
11736
+ for (const file of files) {
11737
+ const fragment = JSON.parse(await readFile4(join17(selected, file), "utf8"));
11738
+ if (!isComponentFragment(fragment))
11739
+ throw new ConfigError([`invalid KeepSnappy component fragment ${file}`]);
11740
+ if (!hashes.has(fragment.source)) {
11741
+ try {
11742
+ hashes.set(fragment.source, createHash2("sha256").update(await readFile4(resolve7(root, fragment.source))).digest("hex"));
11743
+ } catch (error) {
11744
+ if (error.code !== "ENOENT")
11745
+ throw error;
11746
+ hashes.set(fragment.source, null);
11747
+ }
11748
+ }
11749
+ if (hashes.get(fragment.source) !== fragment.hash)
11750
+ continue;
11751
+ matched++;
11752
+ components.push(...fragment.components);
11753
+ }
11754
+ if (files.length > 0 && matched === 0)
11755
+ options.warn?.("component fragments exist but none match the current sources");
11756
+ components.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.line - b.line);
11757
+ const file = join17(directory, "..", "components.json");
11758
+ const text = JSON.stringify(components);
11759
+ const temporary = `${file}.${process.pid}.${randomUUID2()}.tmp`;
11760
+ try {
11761
+ await writeFile2(temporary, text);
11762
+ await rename(temporary, file);
11763
+ } finally {
11764
+ await rm(temporary, { force: true });
11765
+ }
11766
+ const gzip = gzipSync(text);
11767
+ return { file, count: components.length, bytes: Buffer.byteLength(text), gzip, sha256: createHash2("sha256").update(gzip).digest("hex") };
11768
+ }
10910
11769
  function stripSourcesContent(map) {
10911
11770
  if (!isObject(map))
10912
11771
  throw new ConfigError(["not a source map: the file is not a JSON object"]);
@@ -10982,21 +11841,35 @@ async function uploadSourcemaps(opts) {
10982
11841
  }
10983
11842
  return out;
10984
11843
  }
10985
- 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] <map files...>";
11844
+ 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...>
11845
+ keepsnappy sourcemaps upload --eas (e2e EAS build: infer build, platform, map and kind; skips missing inputs and never fails the build)`;
10986
11846
  async function sourcemapsCommand(args, rt) {
11847
+ try {
11848
+ return await uploadCommand(args, rt);
11849
+ } catch (error) {
11850
+ if (!args.includes("--eas"))
11851
+ throw error;
11852
+ rt.print("keepsnappy: source map upload failed (build not affected)");
11853
+ return 0;
11854
+ }
11855
+ }
11856
+ async function uploadCommand(args, rt) {
10987
11857
  let parsed;
10988
11858
  try {
10989
11859
  parsed = parseArgs2({
10990
11860
  args: [...args],
10991
11861
  allowPositionals: true,
10992
11862
  options: {
11863
+ eas: { type: "boolean" },
10993
11864
  build: { type: "string" },
10994
11865
  platform: { type: "string" },
10995
11866
  kind: { type: "string" },
10996
11867
  flavour: { type: "string" },
10997
11868
  api: { type: "string" },
10998
11869
  "ingest-url": { type: "string" },
10999
- "key-env": { type: "string" }
11870
+ "key-env": { type: "string" },
11871
+ "project-root": { type: "string" },
11872
+ "components-config": { type: "string" }
11000
11873
  }
11001
11874
  });
11002
11875
  } catch (e) {
@@ -11006,6 +11879,40 @@ async function sourcemapsCommand(args, rt) {
11006
11879
  const [sub, ...files] = positionals;
11007
11880
  if (sub !== "upload")
11008
11881
  throw new ConfigError([SOURCEMAPS_USAGE]);
11882
+ const keyEnv = f["key-env"] ?? "KEEPSNAPPY_KEY";
11883
+ const key = projectKey(rt.env, keyEnv) || (f.eas && !f["key-env"] ? rt.env.KEEPSNAPPY_CI_KEY : undefined);
11884
+ const url = f.api ?? f["ingest-url"] ?? envValue(rt.env, "API") ?? (f.eas ? rt.env.EXPO_PUBLIC_KEEPSNAPPY_INGEST_URL ?? "" : DEFAULT_API_URL);
11885
+ let preparedFiles;
11886
+ if (f.eas) {
11887
+ const skip = (reason) => {
11888
+ rt.print(`keepsnappy: no source map upload (${reason})`);
11889
+ return 0;
11890
+ };
11891
+ if (rt.env.EAS_BUILD_PROFILE !== "e2e")
11892
+ return skip("profile is not e2e");
11893
+ f.platform ??= rt.env.EAS_BUILD_PLATFORM;
11894
+ if (f.platform !== "ios" && f.platform !== "android")
11895
+ return skip("platform must be ios or android");
11896
+ f.build ??= rt.env.EAS_BUILD_GIT_COMMIT_HASH;
11897
+ if (!key)
11898
+ return skip("key missing");
11899
+ if (!url)
11900
+ return skip("API URL missing");
11901
+ if (!f.build)
11902
+ return skip("commit missing");
11903
+ f.kind ??= f.platform === "ios" ? "packager" : "hermes";
11904
+ f.flavour ??= "e2e";
11905
+ if (files.length === 0)
11906
+ files.push(f.platform === "ios" ? "main.jsbundle.packager.map" : "android/app/build/generated/sourcemaps/react/release/index.android.bundle.map");
11907
+ preparedFiles = new Map;
11908
+ try {
11909
+ const read = rt.readFile ?? ((path) => readFile4(path, "utf8"));
11910
+ for (const file of files)
11911
+ preparedFiles.set(file, await read(file));
11912
+ } catch {
11913
+ return skip("map missing or unreadable");
11914
+ }
11915
+ }
11009
11916
  const problems = [];
11010
11917
  if (!f.build)
11011
11918
  problems.push("--build <full commit sha (git rev-parse HEAD) or build id> is required");
@@ -11015,13 +11922,10 @@ async function sourcemapsCommand(args, rt) {
11015
11922
  problems.push("--kind must be hermes or packager");
11016
11923
  if (files.length === 0)
11017
11924
  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
11925
  if (!key)
11021
11926
  problems.push(`the project key (run scope) is missing: set the environment variable ${keyEnv}`);
11022
11927
  if (problems.length)
11023
11928
  throw new ConfigError(problems);
11024
- const url = f.api ?? f["ingest-url"] ?? envValue(rt.env, "API") ?? DEFAULT_API_URL;
11025
11929
  const done = await uploadSourcemaps({
11026
11930
  url,
11027
11931
  key,
@@ -11031,11 +11935,18 @@ async function sourcemapsCommand(args, rt) {
11031
11935
  ...f.kind ? { kind: f.kind } : {},
11032
11936
  ...f.flavour ? { flavour: f.flavour } : {},
11033
11937
  ...rt.fetch ? { fetch: rt.fetch } : {},
11034
- ...rt.readFile ? { readFile: rt.readFile } : {}
11938
+ ...preparedFiles ? { readFile: async (path) => preparedFiles.get(path) } : rt.readFile ? { readFile: rt.readFile } : {}
11035
11939
  });
11036
11940
  for (const d of done) {
11037
11941
  rt.print(`${d.file}: uploaded to build ${d.buildId} (${d.gzBytes} bytes gzip, sourcesContent removed from ${d.strippedSources} sources)`);
11038
11942
  }
11943
+ try {
11944
+ const components = await prepareComponents(f["project-root"] ?? process.cwd(), { configuration: f["components-config"], warn: (message) => rt.print(`warning: ${message}`) });
11945
+ if (components)
11946
+ rt.print(`${components.file}: prepared ${components.count} components; upload is not yet supported by the API`);
11947
+ } catch (error) {
11948
+ rt.print(`warning: component map preparation skipped: ${error.message}`);
11949
+ }
11039
11950
  return 0;
11040
11951
  }
11041
11952
 
@@ -11043,36 +11954,105 @@ async function sourcemapsCommand(args, rt) {
11043
11954
  import { parseArgs as parseArgs3 } from "node:util";
11044
11955
 
11045
11956
  // packages/mcp/dist/src/answer.js
11957
+ var SHORT_SESSIONS = 3;
11958
+ var SHORT_POINTS = 5;
11959
+ var SHORT_PLACES = 5;
11960
+ var SHORT_FORM_NOTES = {
11961
+ list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
11962
+ get_finding: "the why, seen and layer lines, the component lines, the memory evidence lines other than the judged measure and the prompt",
11963
+ session_places: "the component lines",
11964
+ 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`,
11965
+ main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
11966
+ place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
11967
+ window_history: `the runs older than the newest ${SHORT_POINTS}`,
11968
+ pr_status: "the per-journey dashboard links and the heat note",
11969
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
11970
+ };
11971
+ var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
11972
+
11973
+ class Rows {
11974
+ rows = [];
11975
+ add(short, ...texts) {
11976
+ for (const t of texts)
11977
+ for (const l of t.split(`
11978
+ `))
11979
+ this.rows.push({ text: l, short });
11980
+ return this;
11981
+ }
11982
+ both(...texts) {
11983
+ return this.add(true, ...texts);
11984
+ }
11985
+ full(...texts) {
11986
+ return this.add(false, ...texts);
11987
+ }
11988
+ detail(...texts) {
11989
+ for (const text of texts)
11990
+ for (const line of text.split(`
11991
+ `))
11992
+ this.rows.push({ text: line, short: false, notice: false });
11993
+ return this;
11994
+ }
11995
+ render(form, left = []) {
11996
+ if (form !== "short")
11997
+ return this.rows.map((r) => r.text).join(`
11998
+ `);
11999
+ const kept = [];
12000
+ for (const r of this.rows) {
12001
+ if (!r.short)
12002
+ continue;
12003
+ if (r.text === "" && (!kept.length || kept[kept.length - 1] === ""))
12004
+ continue;
12005
+ kept.push(r.text);
12006
+ }
12007
+ if (kept[kept.length - 1] === "")
12008
+ kept.pop();
12009
+ if (this.rows.some((r) => !r.short && r.notice !== false && r.text.trim() !== ""))
12010
+ kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
12011
+ return kept.join(`
12012
+ `);
12013
+ }
12014
+ }
11046
12015
  var LABEL_W = 14;
11047
12016
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
11048
- var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
12017
+ var plural2 = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
12018
+ var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
11049
12019
  function formatFinding(f, opts = {}) {
11050
- const out = [
11051
- f.answer.headline,
11052
- line("What changed", f.answer.what_changed),
11053
- line("Where", f.answer.where),
11054
- line("How sure", f.answer.how_sure),
11055
- line("Why", f.answer.why),
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
- ];
12020
+ const r = new Rows;
12021
+ r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
12022
+ if (f.lane)
12023
+ r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
12024
+ r.full(line("Why", f.answer.why));
12025
+ r.both(line("What to do", f.answer.what_to_do));
12026
+ 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("; ")}` : ""}`));
12027
+ r.both(line("Id", f.id));
11061
12028
  if (f.why.components.length) {
11062
- out.push(line("Components", ""));
11063
- for (const c of f.why.components) {
11064
- out.push(` ${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)"}`);
12029
+ r.full(line("Components", ""));
12030
+ const names = componentReadNames(f.why.components.map((c) => ({ key: c.key, name: c.component, ...c.file ? { file: c.file } : {} })));
12031
+ for (const [i, c] of f.why.components.entries()) {
12032
+ r.full(` ${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms ${c.key ? READ_WORDS.componentTime : READ_WORDS.ownTime}, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
12033
+ }
12034
+ }
12035
+ const memory = f.why.memory ?? [];
12036
+ if (memory.length) {
12037
+ r.both(` Memory evidence: ${f.where.on === "main" ? "earlier → latest" : "main → PR"} (values are not a verdict)`);
12038
+ for (const m of memory) {
12039
+ const value = (n) => n === null ? "not stored" : fieldValueText(m.metric, n);
12040
+ r.add(m.metric === f.what.metric, ` ${m.label}: ${value(m.before)} → ${value(m.after)}${m.word ? ` (${m.word})` : ""}`);
11065
12041
  }
11066
12042
  }
11067
12043
  if (opts.prompt)
11068
- out.push("", f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
11069
- return out.join(`
11070
- `);
12044
+ r.full("", promptText(f));
12045
+ return r.render(opts.form, [
12046
+ "the why, seen and layer lines",
12047
+ ...f.why.components.length ? [plural2(f.why.components.length, "component line")] : [],
12048
+ ...memory.some((m) => m.metric !== f.what.metric) ? [plural2(memory.filter((m) => m.metric !== f.what.metric).length, "memory evidence line")] : [],
12049
+ ...opts.prompt ? ["the prompt"] : []
12050
+ ]);
11071
12051
  }
11072
12052
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
11073
12053
  var LANES_SHOWN = 8;
11074
12054
  var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
11075
- function measuredLine(l) {
12055
+ function measuredLines(l, r, opts) {
11076
12056
  const f = l.filters;
11077
12057
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
11078
12058
  const m = l.measured;
@@ -11080,26 +12060,37 @@ function measuredLine(l) {
11080
12060
  if (!n) {
11081
12061
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
11082
12062
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
11083
- return 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.`;
12063
+ r.both(m.run_sets ? `${scope}: ${plural2(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.`);
12064
+ return [];
11084
12065
  }
11085
12066
  const lane = (x) => {
11086
12067
  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}`;
12068
+ 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
12069
  };
11089
- if (m.lanes.length === 1)
11090
- return `${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`;
12070
+ if (m.lanes.length === 1) {
12071
+ r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
12072
+ r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
12073
+ return [];
12074
+ }
11091
12075
  const shown = m.lanes.slice(0, LANES_SHOWN);
12076
+ r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12077
+ for (const x of shown) {
12078
+ const keep = !x.judged || !!x.context;
12079
+ r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12080
+ r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
12081
+ }
12082
+ if (m.lanes.length > shown.length)
12083
+ r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
12084
+ const judgedLeft = shown.filter((x) => x.judged && !x.context).length;
11092
12085
  return [
11093
- `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`,
11094
- ...shown.map((x) => ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`),
11095
- ...m.lanes.length > shown.length ? [` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`] : []
11096
- ].join(`
11097
- `);
12086
+ ...judgedLeft ? [plural2(judgedLeft, "judged lane")] : [],
12087
+ ...m.lanes.length > shown.length ? [plural2(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []
12088
+ ];
11098
12089
  }
11099
12090
  function countsLine2(l) {
11100
12091
  const c = l.counts;
11101
12092
  const parts = [
11102
- c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
12093
+ c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} ${NOT_BLOCKING})` : ""}` : "",
11103
12094
  c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
11104
12095
  c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
11105
12096
  ].filter(Boolean);
@@ -11108,31 +12099,135 @@ function countsLine2(l) {
11108
12099
  const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
11109
12100
  const heated = c.heat_runs ?? 0;
11110
12101
  const more = [
11111
- heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
12102
+ heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (run health, layer 3)` : "",
11112
12103
  state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
11113
12104
  ].filter(Boolean);
11114
12105
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
11115
12106
  return line.charAt(0).toUpperCase() + line.slice(1);
11116
12107
  }
11117
- function formatFindingList(l) {
11118
- const out = [measuredLine(l)];
12108
+ function formatFindingList(l, opts = {}) {
12109
+ const r = new Rows;
12110
+ const lanesLeft = measuredLines(l, r, opts);
11119
12111
  if (l.measured.newest || l.total)
11120
- out.push(countsLine2(l));
12112
+ r.both(countsLine2(l));
11121
12113
  l.findings.forEach((f, i) => {
11122
- out.push("", `${l.offset + i + 1}. ${f.answer.headline}`, ` ${f.answer.where}`, ` ${f.answer.how_sure}`, ` ${f.answer.lifecycle} Id ${f.id}`);
12114
+ r.both("", `${l.offset + i + 1}. ${f.answer.headline}`);
12115
+ r.add(isWorseState(f.state), ` ${f.answer.where}`);
12116
+ r.full(` ${f.answer.how_sure}`);
12117
+ r.both(` ${f.answer.lifecycle} Id ${f.id}`);
11123
12118
  });
11124
12119
  const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
11125
12120
  if (l.next_offset !== null)
11126
- out.push("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
12121
+ r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
11127
12122
  else if (l.offset > 0)
11128
- out.push("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
12123
+ r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
11129
12124
  if (l.findings.length)
11130
- out.push("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
11131
- return out.join(`
11132
- `);
12125
+ r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
12126
+ const notWorse = l.findings.filter((f) => !isWorseState(f.state)).length;
12127
+ return r.render(opts.form, [...l.findings.length ? [plural2(l.findings.length, "how-sure line")] : [], ...notWorse ? [plural2(notWorse, "place line")] : [], ...lanesLeft]);
12128
+ }
12129
+ var STATUS_LEAD = {
12130
+ ok: "ok",
12131
+ warns: "nothing blocks, results to look at",
12132
+ blocks: "blocked",
12133
+ "cant-tell": "not ok yet, nothing was compared",
12134
+ "not-judged": "not ok yet, the newest run set was not judged",
12135
+ "never-measured": "not ok yet, never measured"
12136
+ };
12137
+ function formatBranchStatus(s, opts = {}) {
12138
+ const r = new Rows;
12139
+ r.both(`${s.branch}: ${STATUS_LEAD[s.result]}`, s.reason);
12140
+ if (s.lanes.length)
12141
+ r.both("");
12142
+ for (const l of s.lanes) {
12143
+ const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
12144
+ const sha = l.commit?.sha?.slice(0, 7);
12145
+ r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown} · ${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)}` : ""}`);
12146
+ r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
12147
+ for (const f of l.worst)
12148
+ r.both(` ${f.id} ${f.headline}`);
12149
+ if (l.present_findings > l.worst.length)
12150
+ r.both(` … ${l.present_findings - l.worst.length} more: keepsnappy findings --branch ${s.branch}`);
12151
+ if (l.link)
12152
+ r.full(` ${l.link}`);
12153
+ }
12154
+ const links = s.lanes.filter((l) => l.link).length;
12155
+ const heated = s.counts.heat_runs ?? 0;
12156
+ const more = [
12157
+ [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
12158
+ [
12159
+ true,
12160
+ s.not_judged_findings ? `${plural2(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` : ""
12161
+ ],
12162
+ [true, statusProblemsLine(s)],
12163
+ [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
12164
+ [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
12165
+ [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
12166
+ ];
12167
+ const shownMore = more.filter(([, text]) => text);
12168
+ if (shownMore.length)
12169
+ r.both("");
12170
+ for (const [short, text] of shownMore)
12171
+ r.add(short, text);
12172
+ return r.render(opts.form, [...links ? [plural2(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
12173
+ }
12174
+ function statusProblemsLine(s) {
12175
+ const p = s.problems;
12176
+ if (!p?.count)
12177
+ return "";
12178
+ return `${plural2(p.places, "place")} over budget in this branch’s own runs (${p.far_count ? `${p.far_count} far over budget; ` : ""}not a regression, never blocks): list_problems, or keepsnappy problems --branch ${s.branch}`;
12179
+ }
12180
+ var unreadWords = (n) => `${n} journey × phone not read`;
12181
+ var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12182
+ function formatProblemList(l, opts = {}) {
12183
+ const r = new Rows;
12184
+ const b = l.filters.branch;
12185
+ const c = l.counts;
12186
+ const unread = c.unread_lanes ?? 0;
12187
+ const read = l.lanes.length - unread;
12188
+ if (!l.measured.run_sets) {
12189
+ r.both(`No KeepSnappy measurement on ${b === l.baseline_branch ? b : `branch ${b}`}: nothing is known about its problems.`);
12190
+ return r.render(opts.form);
12191
+ }
12192
+ if (!l.lanes.length) {
12193
+ r.both(`${b}: ${plural2(l.measured.run_sets, "run set")} measured, but no judgement yet to read problems from.`);
12194
+ return r.render(opts.form);
12195
+ }
12196
+ if (c.count)
12197
+ r.both(`${b}: ${plural2(c.places, "place")} over budget in ${b === l.baseline_branch ? "main’s" : "this branch’s"} own runs${c.far_count ? ` (${c.far_count} far over budget)` : ""}`);
12198
+ else if (read)
12199
+ r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12200
+ else
12201
+ r.both(`${b}: no problem could be read: ${unread} journey × phone has a stored result that is not current, so nothing is known about its problems.`);
12202
+ r.both(l.note);
12203
+ if (read && unread)
12204
+ r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
12205
+ l.problems.forEach((p, i) => {
12206
+ const keep = i < SHORT_PLACES;
12207
+ r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
12208
+ r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
12209
+ const top = p.top_components[0];
12210
+ const topName = componentReadNames(p.top_components)[0];
12211
+ const detail = [p.work, top ? `heaviest ${topName}${opts.verbose && top.key ? ` (${top.key})` : ""} ${top.self_text}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
12212
+ if (detail.length)
12213
+ r.full(` ${detail.join("; ")}`);
12214
+ if (p.url)
12215
+ r.full(` ${p.url}`);
12216
+ });
12217
+ const shown = l.problems.length ? `Showing ${l.offset + 1}–${l.offset + l.problems.length} of ${l.total}` : "";
12218
+ if (l.next_offset !== null)
12219
+ r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
12220
+ else if (l.offset > 0)
12221
+ r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
12222
+ const detailLines = l.problems.some((p) => p.work || p.top_components.length || p.new_component || p.range_text);
12223
+ return r.render(opts.form, [
12224
+ ...detailLines ? ["the work and component lines"] : [],
12225
+ ...l.problems.some((p) => p.url) ? ["the dashboard links"] : [],
12226
+ ...l.problems.length > SHORT_PLACES ? [`${plural2(l.problems.length - SHORT_PLACES, "problem")} beyond the fifth`] : []
12227
+ ]);
11133
12228
  }
11134
12229
  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
- function formatCompare(r) {
12230
+ function formatCompare(r, opts = {}) {
11136
12231
  const out = [
11137
12232
  `Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`,
11138
12233
  `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`,
@@ -11142,37 +12237,151 @@ function formatCompare(r) {
11142
12237
  out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
11143
12238
  for (const w of r.windows) {
11144
12239
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
11145
- if (!ms.length)
12240
+ const c = w.component_page ?? w.components;
12241
+ if (!ms.length && !c)
11146
12242
  continue;
11147
- out.push("", w.window_name);
12243
+ out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
12244
+ const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
12245
+ const components = [...c?.rows ?? [], ...singles];
12246
+ const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
12247
+ const shown = new Map(components.map((row, i) => [row.key, names[i]]));
12248
+ for (const row of c?.rows ?? [])
12249
+ out.push(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
12250
+ for (const [label, rows] of [
12251
+ [READ_WORDS.onlyB, c?.added ?? []],
12252
+ [READ_WORDS.onlyA, c?.removed ?? []]
12253
+ ]) {
12254
+ for (const row of rows)
12255
+ out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
12256
+ }
12257
+ const page = w.component_page;
12258
+ if (page) {
12259
+ for (const side of ["a", "b"]) {
12260
+ if (!page.measured[side])
12261
+ out.push(` ${notMeasuredText(side)}`);
12262
+ for (const row of page.uncompared?.[side] ?? [])
12263
+ out.push(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
12264
+ }
12265
+ out.push(` ${componentPageText(page.page, components.length)}`);
12266
+ } else if (c?.totals)
12267
+ out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
12268
+ if (c?.omissions)
12269
+ for (const side of ["a", "b"]) {
12270
+ if (page && !page.measured[side])
12271
+ continue;
12272
+ const o = c.omissions[side];
12273
+ if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
12274
+ out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
12275
+ }
11148
12276
  for (const m of ms)
11149
12277
  out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
11150
12278
  }
11151
12279
  return out.join(`
11152
12280
  `);
11153
12281
  }
11154
- function formatHistory(h) {
11155
- const out = [`${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"}`];
12282
+ function formatHistory(h, opts = {}) {
12283
+ const r = new Rows;
12284
+ r.both(`${h.window_name}${opts.verbose ? ` (${h.window})` : ""} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural2(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
11156
12285
  const ms = h.measures.slice(0, 4);
11157
12286
  if (h.points.length) {
11158
- out.push(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
11159
- for (const p of h.points) {
11160
- out.push(` ${(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)}`);
11161
- }
12287
+ r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
12288
+ h.points.forEach((p, i) => {
12289
+ 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)}`);
12290
+ });
11162
12291
  }
11163
12292
  if (h.findings.length)
11164
- out.push("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
11165
- return out.join(`
11166
- `);
12293
+ r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
12294
+ const older = Math.max(0, h.points.length - SHORT_POINTS);
12295
+ return r.render(opts.form, older ? [plural2(older, "older run")] : []);
11167
12296
  }
11168
12297
  function formatCommits(c) {
11169
12298
  return [
11170
- `${plural(c.commits.length, "measured commit")} (${plural(c.sessions, "session")}) on ${c.branch}, oldest first:`,
12299
+ `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
11171
12300
  ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
11172
12301
  c.note
11173
12302
  ].join(`
11174
12303
  `);
11175
12304
  }
12305
+ var list = (xs) => xs.join(", ");
12306
+ function versionText(label, counts) {
12307
+ const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
12308
+ const name = (k) => k === "unknown" ? "not recorded" : k;
12309
+ if (values.length === 0)
12310
+ return "";
12311
+ if (values.length === 1)
12312
+ return `${label} ${name(values[0])}`;
12313
+ return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
12314
+ }
12315
+ function formatSessionPlaces(p, opts = {}) {
12316
+ const s = p.session;
12317
+ const sha = s.commit?.sha?.slice(0, 7);
12318
+ 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";
12319
+ const v = s.versions;
12320
+ const codes = Object.keys(s.runs.invalid_codes);
12321
+ 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)})` : ""}` : ""}. ${[
12322
+ versionText("SDK", v.sdk),
12323
+ versionText("driver", v.driver),
12324
+ versionText("driver version", v.driver_version),
12325
+ versionText("app", v.app_build),
12326
+ versionText("journey version", v.journey_version),
12327
+ `derivation ${v.derivation}`,
12328
+ `stats ${v.stats}`
12329
+ ].filter(Boolean).join(", ")}.`);
12330
+ if (s.not_computed)
12331
+ return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
12332
+ if (s.stale)
12333
+ 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);
12334
+ const by = p.filters.measure;
12335
+ const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
12336
+ 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` : ""}.`, "");
12337
+ let componentLines = 0;
12338
+ p.places.forEach((place, i) => {
12339
+ const m = place.measures.find((x) => x.measure === by);
12340
+ const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
12341
+ const names = componentReadNames(place.components);
12342
+ const comps = place.components.map((c, i) => `${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural2(c.renders_median, "render")}` : ""})`);
12343
+ r.both(`${p.offset + i + 1}. ${place.window_name}${opts.verbose ? ` (${place.window})` : ""}: ${value}`);
12344
+ if (place.components_page)
12345
+ r.detail(` ${componentPageText(place.components_page, place.components.length)}`);
12346
+ for (const o of (place.omissions ?? []).filter((o) => o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0))
12347
+ r.detail(` ${omissionText(o)}`);
12348
+ const slowest = place.measures.find((m) => m.measure === "commit_max_ms");
12349
+ if (by !== "commit_max_ms" && slowest)
12350
+ r.detail(` ${READ_WORDS.slowest}: ${fieldValueText("commit_max_ms", slowest.median)}`);
12351
+ for (const detail of place.details ?? []) {
12352
+ if (detail.slowestCommit)
12353
+ r.detail(` ${detail.run_id}: ${READ_WORDS.slowest}: ${exactComponentMs(detail.slowestCommit.ms)}`);
12354
+ r.detail(` ${detail.run_id}: ${OUTSIDE_APP_NAME}: ${detail.outside ? exactComponentMs(detail.outside.selfMs) : "–"}; ${OTHER_RENDER_WORK_NAME}: ${detail.remainderMs === null ? "–" : exactComponentMs(detail.remainderMs)}`);
12355
+ for (const l of detail.outside?.rolledUp ?? [])
12356
+ r.detail(` ${libraryLineText(l)}`);
12357
+ if (detail.outside?.rolledUpRest)
12358
+ r.detail(` ${rolledUpRestText(detail.outside.rolledUpRest)}`);
12359
+ }
12360
+ for (const [ci, c] of place.components.entries()) {
12361
+ if (c.detail === "ms-only" && !c.runs?.length)
12362
+ r.detail(` ${names[ci]}: ${READ_WORDS.detailUnavailable}`);
12363
+ for (const d of c.runs ?? [])
12364
+ if (d.detail === "ms-only")
12365
+ r.detail(` ${names[ci]} · ${d.run_id}: ${d.selfMs === null ? "–" : exactComponentMs(d.selfMs)}; ${READ_WORDS.detailUnavailable}`);
12366
+ for (const d of c.runs ?? [])
12367
+ if (d.detail === "full") {
12368
+ if (d.parent)
12369
+ r.detail(` ${names[ci]} · ${d.run_id}: ${READ_WORDS.parent} ${componentReadName({ key: d.parent, name: d.parent })}`);
12370
+ if (d.rolledUpMs !== undefined && d.selfMs !== null)
12371
+ r.detail(` ${names[ci]} · ${d.run_id}: ${rolledUpText(d.rolledUpMs, d.selfMs)}`, ...(d.rolledUp ?? []).map((l) => ` ${libraryLineText(l)}`), ...d.rolledUpRest ? [` ${rolledUpRestText(d.rolledUpRest)}`] : []);
12372
+ }
12373
+ }
12374
+ if (comps.length) {
12375
+ r.full(` Components: ${list(comps)}`);
12376
+ componentLines++;
12377
+ }
12378
+ });
12379
+ if (!p.places.length)
12380
+ r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
12381
+ else if (p.next_offset !== null)
12382
+ 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}).`);
12383
+ return r.render(opts.form, componentLines ? [plural2(componentLines, "component line")] : []);
12384
+ }
11176
12385
  function formatLayers(l) {
11177
12386
  return [
11178
12387
  l.note,
@@ -11182,6 +12391,106 @@ function formatLayers(l) {
11182
12391
  ].join(`
11183
12392
  `);
11184
12393
  }
12394
+ var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
12395
+ function formatMainSessions(h, opts = {}) {
12396
+ const baseline = { baseline: { sessions: h.baseline_sessions } };
12397
+ const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
12398
+ if (!h.sessions.length)
12399
+ r.both("No main sessions measured for this journey and phone.");
12400
+ const recent = new Set(h.sessions.slice(-SHORT_SESSIONS));
12401
+ const latest = h.sessions[h.sessions.length - 1];
12402
+ let otherChanges = 0;
12403
+ for (const s of h.sessions) {
12404
+ const k = recent.has(s);
12405
+ r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural2(s.runs, "run")})`);
12406
+ if (!s.places)
12407
+ r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
12408
+ else
12409
+ 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)}` : ""}`);
12410
+ if (s.waiting_sessions)
12411
+ r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
12412
+ if (s.memory)
12413
+ 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)}` : ""}`);
12414
+ for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
12415
+ r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
12416
+ for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
12417
+ r.add(s === latest, ` Main change: ${windowQualified(c.place)}${opts.verbose ? ` (${c.place}; ${c.ref})` : ""} ${storedRefWords(c.ref, h.changes.filter((x) => x.place === c.place && x.from_run_set_id === c.from_run_set_id).map((x) => x.ref))}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
12418
+ if (k && s !== latest)
12419
+ otherChanges++;
12420
+ }
12421
+ }
12422
+ const older = h.sessions.length - recent.size;
12423
+ return r.render(opts.form, [
12424
+ ...older ? [plural2(older, "older session")] : [],
12425
+ ...otherChanges ? [plural2(otherChanges, "main change") + " of the other recent sessions"] : []
12426
+ ]);
12427
+ }
12428
+ function formatMainSessionPlaces(p, opts = {}) {
12429
+ const r = new Rows;
12430
+ if (p.status !== "ok")
12431
+ return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
12432
+ const baselineSessions = p.baseline_sessions;
12433
+ r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
12434
+ for (const x of p.places)
12435
+ r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, p.places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
12436
+ const inNorm = p.places.filter((x) => x.tier === "same").length;
12437
+ return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
12438
+ }
12439
+ var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
12440
+ var previousLine = (p, verbose = false, peers = []) => ` ${windowQualified(p.place)}${verbose ? ` (${p.place}; ${p.ref})` : ""} ${storedRefWords(p.ref, peers)}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status === "gone" ? p.ref.startsWith("b:component:") ? JOURNEY_ROW_WORDS.removed : JOURNEY_ROW_WORDS.notInRun : p.status === "new" ? JOURNEY_ROW_WORDS.new : 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 ?? "–"}`;
12441
+ function formatMainVsPrevious(m, opts = {}) {
12442
+ 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}.`);
12443
+ if (m.cut === "unknown")
12444
+ r.both("History restart: unknown; current summaries unavailable.");
12445
+ else if (m.cut)
12446
+ r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
12447
+ if (m.not_computed.length)
12448
+ r.both(`not_computed: ${m.not_computed.join(", ")}`);
12449
+ if (m.stale.length)
12450
+ r.both(`stale: ${m.stale.join(", ")}`);
12451
+ if (m.journey)
12452
+ r.both("Whole journey:", previousLine(m.journey, opts.verbose));
12453
+ m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p, opts.verbose, m.places.filter((row) => row.place === p.place).map((row) => row.ref))));
12454
+ if (m.next_offset !== null)
12455
+ r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
12456
+ const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
12457
+ return r.render(opts.form, smaller ? [plural2(smaller, "smaller mover")] : []);
12458
+ }
12459
+ function formatPlaceTrend(t, opts = {}) {
12460
+ const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
12461
+ const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
12462
+ const olderSessions = new Set;
12463
+ let olderRestarts = 0;
12464
+ for (const session of t.sessions) {
12465
+ const k = newest.has(session.run_set_id);
12466
+ if (session.restarts_known === false)
12467
+ r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
12468
+ for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
12469
+ r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
12470
+ if (!k)
12471
+ olderRestarts++;
12472
+ }
12473
+ for (const place of t.places) {
12474
+ const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
12475
+ if (!value)
12476
+ continue;
12477
+ const unit = place.unit ? ` ${place.unit}` : "";
12478
+ if (!k)
12479
+ olderSessions.add(session.run_set_id);
12480
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
12481
+ }
12482
+ }
12483
+ if (!t.sessions.length)
12484
+ r.both("No main sessions.");
12485
+ if (t.not_computed.length)
12486
+ r.both(`Not computed: ${t.not_computed.join(", ")}`);
12487
+ if (t.stale.length)
12488
+ r.both(`Stale: ${t.stale.join(", ")}`);
12489
+ return r.render(opts.form, [
12490
+ ...olderSessions.size ? [plural2(olderSessions.size, "older session")] : [],
12491
+ ...olderRestarts ? [plural2(olderRestarts, "older restart marker")] : []
12492
+ ]);
12493
+ }
11185
12494
 
11186
12495
  // packages/mcp/dist/src/client.js
11187
12496
  class ApiError extends Error {
@@ -11196,12 +12505,22 @@ class ApiError extends Error {
11196
12505
  }
11197
12506
  async function get(t, path, query = {}) {
11198
12507
  const u = new URL(path, t.url.endsWith("/") ? t.url : `${t.url}/`);
11199
- for (const [k, v] of Object.entries(query))
11200
- if (v !== undefined && v !== "")
12508
+ for (const [k, v] of Object.entries(query)) {
12509
+ if (Array.isArray(v))
12510
+ for (const value of v)
12511
+ u.searchParams.append(k, value);
12512
+ else if (v !== undefined && v !== "")
11201
12513
  u.searchParams.set(k, String(v));
12514
+ }
11202
12515
  const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
11203
12516
  const text = await r.text();
11204
- const body = text ? JSON.parse(text) : {};
12517
+ let body;
12518
+ try {
12519
+ body = text ? JSON.parse(text) : {};
12520
+ } catch {
12521
+ const page = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
12522
+ throw new ApiError(r.status, "not_json", `the API answered ${r.status} with a body that is not JSON${page ? `: ${page}` : ""}`);
12523
+ }
11205
12524
  if (!r.ok) {
11206
12525
  const code = typeof body.error === "string" ? body.error : String(r.status);
11207
12526
  const message = typeof body.message === "string" ? body.message : code;
@@ -11211,31 +12530,60 @@ async function get(t, path, query = {}) {
11211
12530
  return body;
11212
12531
  }
11213
12532
  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 });
12533
+ var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
12534
+ var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
11214
12535
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
11215
- var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
12536
+ var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
11216
12537
  var windowHistory = (t, q) => get(t, "v1/history", q);
11217
12538
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
11218
12539
  var layers = (t) => get(t, "v1/layers");
12540
+ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
12541
+ run_set: f.run_set,
12542
+ journey: f.journey,
12543
+ phone: f.phone,
12544
+ branch: f.branch,
12545
+ measure: f.measure,
12546
+ sort: f.sort,
12547
+ window: f.window,
12548
+ limit: f.limit,
12549
+ offset: f.offset || undefined,
12550
+ top_components: f.top_components,
12551
+ detail: f.detail,
12552
+ component_offset: f.component_offset,
12553
+ component_limit: f.component_limit
12554
+ });
12555
+ var SESSION_SORTS = ["median", "p90", "mad"];
12556
+ var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
12557
+ var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
12558
+ var mainVsPrevious = (t, q) => {
12559
+ const { run_set, ...query } = q;
12560
+ return get(t, `v1/main/sessions/${encodeURIComponent(run_set ?? "latest")}/vs-previous`, query);
12561
+ };
12562
+ var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
11219
12563
 
11220
12564
  // 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
- finding <id> [--prompt] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
11223
- compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
11224
- history <journey> <window> [--phone MODEL] [--json] (window id or its name, e.g. "Tap · “live chat send”")
11225
- commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
12565
+ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--short] [--json]
12566
+ 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)
12567
+ problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
12568
+ (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
12569
+ finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
12570
+ compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
12571
+ history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
12572
+ commits <from> [<to>] [--branch NAME] [--json] (measured commits between two run sets or commit hashes)
12573
+ 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]
12574
+ (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)
12575
+ main --journey KEY --phone MODEL [--sessions N | --session ID] [--vs-previous] [--measure REF] [--limit N] [--offset N] [--short] [--json]
11226
12576
  layers [--json]
12577
+ trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
12578
+ (the words after the journey are one window name; more windows with repeated --window)
12579
+ ${READ_USAGE.components}
12580
+ ${READ_USAGE.verbose}
12581
+ ${READ_USAGE.detailFlag}
12582
+ --short (findings, status, problems, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
11227
12583
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
11228
-
11229
- class UsageError extends ConfigError {
11230
- constructor(lines) {
11231
- super(lines);
11232
- this.message = lines.join(`
11233
- `);
11234
- this.name = "UsageError";
11235
- }
11236
- }
11237
12584
  var STATES = ["present", "not-judged", "resolved", "all"];
11238
- var FINDINGS_COMMANDS = new Set(["findings", "finding", "compare", "history", "commits", "layers"]);
12585
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
12586
+ var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
11239
12587
  async function findingsCommand(cmd, argv, rt) {
11240
12588
  const args = (() => {
11241
12589
  try {
@@ -11243,17 +12591,31 @@ async function findingsCommand(cmd, argv, rt) {
11243
12591
  args: argv,
11244
12592
  allowPositionals: true,
11245
12593
  options: {
12594
+ verbose: { type: "boolean" },
12595
+ detail: { type: "boolean" },
12596
+ page: { type: "string" },
12597
+ all: { type: "boolean" },
11246
12598
  branch: { type: "string" },
11247
12599
  main: { type: "boolean" },
11248
12600
  journey: { type: "string" },
11249
12601
  phone: { type: "string" },
11250
12602
  state: { type: "string" },
12603
+ session: { type: "string" },
12604
+ "vs-previous": { type: "boolean" },
11251
12605
  limit: { type: "string" },
11252
12606
  offset: { type: "string" },
12607
+ "run-set": { type: "string" },
12608
+ measure: { type: "string" },
12609
+ sort: { type: "string" },
12610
+ "top-components": { type: "string" },
11253
12611
  json: { type: "boolean" },
12612
+ short: { type: "boolean" },
11254
12613
  prompt: { type: "boolean" },
11255
12614
  api: { type: "string" },
11256
- "key-env": { type: "string" }
12615
+ "key-env": { type: "string" },
12616
+ window: { type: "string", multiple: true },
12617
+ ref: { type: "string", multiple: true },
12618
+ sessions: { type: "string" }
11257
12619
  }
11258
12620
  });
11259
12621
  } catch (e) {
@@ -11266,8 +12628,9 @@ async function findingsCommand(cmd, argv, rt) {
11266
12628
  if (!key)
11267
12629
  throw new ConfigError([`a project key with read scope is needed: set the environment variable ${keyEnv}`]);
11268
12630
  const t = { url: o.api ?? envValue(rt.env, "API") ?? DEFAULT_API_URL, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
12631
+ const answerForm = { form: o.short ? "short" : "full", verbose: o.verbose };
11269
12632
  const out = (v, text) => {
11270
- rt.print(o.json ? JSON.stringify(v, null, 2) : text(v));
12633
+ rt.print(o.json ? JSON.stringify(v, null, 2) : text(v, answerForm));
11271
12634
  return 0;
11272
12635
  };
11273
12636
  const need = (n, what) => {
@@ -11277,10 +12640,34 @@ async function findingsCommand(cmd, argv, rt) {
11277
12640
  const limit = o.limit === undefined ? undefined : Number(o.limit);
11278
12641
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200))
11279
12642
  throw new UsageError(["--limit must be a whole number from 1 to 200 (page with --offset)"]);
12643
+ if (o.short && !SHORT_COMMANDS.has(cmd))
12644
+ throw new UsageError([`--short is for ${[...SHORT_COMMANDS].join(", ")}; keepsnappy ${cmd} has only one form`]);
11280
12645
  const offset = o.offset === undefined ? undefined : Number(o.offset);
11281
12646
  if (offset !== undefined && (!Number.isInteger(offset) || offset < 0))
11282
12647
  throw new UsageError(["--offset must be a whole number >= 0"]);
12648
+ const page = o.page === undefined ? undefined : Number(o.page);
12649
+ if (page !== undefined && (!["places", "compare"].includes(cmd) || !Number.isInteger(page) || page < 1))
12650
+ throw new UsageError([READ_USAGE.page]);
12651
+ if (o.all && (!["places", "compare"].includes(cmd) || page !== undefined || o.offset !== undefined || o.short))
12652
+ throw new UsageError([READ_USAGE.all]);
11283
12653
  switch (cmd) {
12654
+ case "main": {
12655
+ if (!o.journey)
12656
+ throw new UsageError(["keepsnappy main needs --journey KEY"]);
12657
+ if (!o.phone)
12658
+ throw new UsageError(["keepsnappy main needs --phone MODEL (exact device model)"]);
12659
+ const sessions = o.sessions === undefined ? undefined : Number(o.sessions);
12660
+ if (sessions !== undefined && (!Number.isInteger(sessions) || sessions < 1 || sessions > 20))
12661
+ throw new UsageError(["--sessions must be a whole number from 1 to 20"]);
12662
+ if (sessions !== undefined && (o.session || o["vs-previous"]))
12663
+ throw new UsageError(["--sessions is for the session list"]);
12664
+ const lane = { journey: o.journey, phone: o.phone };
12665
+ if (o["vs-previous"])
12666
+ return out(await mainVsPrevious(t, { ...lane, run_set: o.session, measure: o.measure, limit, offset }), formatMainVsPrevious);
12667
+ if (o.session)
12668
+ return out(await mainSessionPlaces(t, o.session, lane), formatMainSessionPlaces);
12669
+ return out(await mainSessions(t, { ...lane, sessions }), formatMainSessions);
12670
+ }
11284
12671
  case "findings": {
11285
12672
  if (o.branch && o.main)
11286
12673
  throw new UsageError(["give --branch or --main, not both"]);
@@ -11297,30 +12684,182 @@ async function findingsCommand(cmd, argv, rt) {
11297
12684
  ...offset ? { offset } : {}
11298
12685
  }), formatFindingList);
11299
12686
  }
12687
+ case "status":
12688
+ if (!o.branch)
12689
+ throw new UsageError(["keepsnappy status needs --branch NAME", FINDINGS_USAGE]);
12690
+ return out(await branchStatus(t, o.branch), formatBranchStatus);
12691
+ case "problems":
12692
+ if (!o.branch && !o.main)
12693
+ throw new UsageError(["keepsnappy problems needs --branch NAME or --main", FINDINGS_USAGE]);
12694
+ if (o.branch && o.main)
12695
+ throw new UsageError(["give --branch or --main, not both"]);
12696
+ return out(await listProblems(t, {
12697
+ ...o.branch ? { branch: o.branch } : {},
12698
+ ...o.main ? { main: true } : {},
12699
+ ...o.journey ? { journey: o.journey } : {},
12700
+ ...o.phone ? { phone: o.phone } : {},
12701
+ ...limit ? { limit } : {},
12702
+ ...offset ? { offset } : {}
12703
+ }), formatProblemList);
11300
12704
  case "finding": {
11301
12705
  need(1, "<id>");
11302
12706
  const f = await getFinding(t, p[0]);
11303
12707
  if (o.prompt && !o.json) {
11304
- rt.print(f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
12708
+ rt.print(promptText(f));
11305
12709
  return 0;
11306
12710
  }
11307
- return out(f, (x) => formatFinding(x));
12711
+ return out(f, formatFinding);
11308
12712
  }
11309
- case "compare":
12713
+ case "compare": {
11310
12714
  need(2, "<runA> <runB>");
11311
- return out(await compareRuns(t, p[0], p[1]), formatCompare);
12715
+ const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
12716
+ if (!Number.isInteger(size) || size < 1 || size > 200)
12717
+ throw new UsageError([READ_USAGE.compareTop]);
12718
+ const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
12719
+ const compared = await compareRuns(t, p[0], p[1], query);
12720
+ return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
12721
+ }
11312
12722
  case "history":
11313
12723
  need(2, "<journey> <window>");
11314
12724
  return out(await windowHistory(t, { journey: p[0], window: p.slice(1).join(" "), ...o.phone ? { phone: o.phone } : {}, ...limit ? { limit } : {} }), formatHistory);
11315
12725
  case "commits":
11316
12726
  need(1, "<from>");
11317
12727
  return out(await commitsBetween(t, { from: p[0], ...p[1] ? { to: p[1] } : {}, ...o.branch ? { branch: o.branch } : {} }), formatCommits);
12728
+ case "places": {
12729
+ const sort = o.sort;
12730
+ if (sort && !SESSION_SORTS.includes(sort))
12731
+ throw new UsageError([`--sort is ${SESSION_SORTS.join(", ")}`]);
12732
+ const top = o["top-components"] === undefined ? undefined : Number(o["top-components"]);
12733
+ if (top !== undefined && (!Number.isInteger(top) || top < (page || o.all ? 1 : 0) || top > (page || o.all ? 200 : 20)))
12734
+ throw new UsageError([READ_USAGE.top]);
12735
+ if ((o.window?.length ?? 0) > 1)
12736
+ throw new UsageError(["keepsnappy places takes one --window (more windows: keepsnappy trend)", FINDINGS_USAGE]);
12737
+ const query = {
12738
+ ...o.verbose || o.detail ? { detail: true } : {},
12739
+ ...o["run-set"] ? { run_set: o["run-set"] } : {},
12740
+ ...o.journey ? { journey: o.journey } : {},
12741
+ ...o.phone ? { phone: o.phone } : {},
12742
+ ...o.branch ? { branch: o.branch } : {},
12743
+ ...o.measure ? { measure: o.measure } : {},
12744
+ ...sort ? { sort } : {},
12745
+ ...o.window?.length ? { window: o.window[0] } : {},
12746
+ ...limit ? { limit } : {},
12747
+ ...offset ? { offset } : {},
12748
+ ...page || o.all ? { component_offset: ((page ?? 1) - 1) * (top ?? 5), component_limit: top ?? 5 } : top !== undefined ? { top_components: top } : {}
12749
+ };
12750
+ return out(o.all ? await allSessionPlaces(t, query) : await sessionPlaces(t, query), formatSessionPlaces);
12751
+ }
11318
12752
  case "layers":
11319
12753
  return out(await layers(t), formatLayers);
12754
+ case "trend": {
12755
+ const journey = o.journey ?? p[0];
12756
+ const words = p.slice(o.journey ? 0 : 1).join(" ");
12757
+ const windows = [...words ? [words] : [], ...o.window ?? []];
12758
+ if (!journey || !windows.length || !o.phone)
12759
+ throw new UsageError(["keepsnappy trend needs <journey> <window> --phone MODEL", FINDINGS_USAGE]);
12760
+ const sessions = o.sessions === undefined ? undefined : Number(o.sessions);
12761
+ if (sessions !== undefined && (!Number.isInteger(sessions) || sessions < 1 || sessions > 30))
12762
+ throw new UsageError(["--sessions must be a whole number from 1 to 30"]);
12763
+ return out(await placeTrend(t, { journey, phone: o.phone, window: windows, ref: o.ref, sessions }), formatPlaceTrend);
12764
+ }
11320
12765
  default:
11321
12766
  throw new UsageError([FINDINGS_USAGE]);
11322
12767
  }
11323
12768
  }
12769
+ async function allSessionPlaces(target, query) {
12770
+ let first = await sessionPlaces(target, query);
12771
+ if (first.places.some((p) => !p.components_page))
12772
+ throw new UsageError([READ_USAGE.paging]);
12773
+ if (first.session.not_computed || first.session.stale)
12774
+ return first;
12775
+ const result = { ...first, places: [], offset: 0, next_offset: null };
12776
+ const pinned = { ...query, run_set: first.session.run_set_id, branch: undefined };
12777
+ const visited = new Set;
12778
+ for (;; ) {
12779
+ const places = first.places.map((p) => ({ ...p, components: [...p.components] }));
12780
+ let response = first;
12781
+ for (;; ) {
12782
+ const next = response.places.flatMap((p) => p.components_page?.next_offset ?? []);
12783
+ if (!next.length)
12784
+ break;
12785
+ const component_offset = Math.min(...next);
12786
+ const mark = `${first.offset}:${component_offset}`;
12787
+ if (visited.has(mark))
12788
+ throw new UsageError([READ_USAGE.paging]);
12789
+ visited.add(mark);
12790
+ response = await sessionPlaces(target, { ...pinned, offset: first.offset, component_offset });
12791
+ for (const p of places) {
12792
+ const more = response.places.find((x) => x.window === p.window);
12793
+ if (!more?.components_page)
12794
+ throw new UsageError([READ_USAGE.paging]);
12795
+ const keys = new Set(p.components.map((c) => c.key));
12796
+ p.components.push(...more.components.filter((c) => !keys.has(c.key)));
12797
+ p.components_page = more.components_page;
12798
+ }
12799
+ }
12800
+ result.places.push(...places.map((p) => ({ ...p, ...p.components_page ? { components_page: { ...p.components_page, offset: 0, limit: p.components.length, next_offset: null } } : {} })));
12801
+ if (places.some((p) => p.components_page && p.components.length !== p.components_page.total))
12802
+ throw new UsageError([READ_USAGE.paging]);
12803
+ if (first.next_offset === null) {
12804
+ if (result.places.length !== result.total)
12805
+ throw new UsageError([READ_USAGE.paging]);
12806
+ return result;
12807
+ }
12808
+ const mark = `places:${first.next_offset}`;
12809
+ if (visited.has(mark))
12810
+ throw new UsageError([READ_USAGE.paging]);
12811
+ visited.add(mark);
12812
+ first = await sessionPlaces(target, { ...pinned, offset: first.next_offset, component_offset: 0 });
12813
+ }
12814
+ }
12815
+ async function allCompare(target, result, size) {
12816
+ let response = result;
12817
+ const visited = new Set;
12818
+ for (;; ) {
12819
+ if (response.windows.some((w) => w.components && !w.component_page))
12820
+ throw new UsageError([READ_USAGE.paging]);
12821
+ const offsets = response.windows.flatMap((w) => w.component_page?.page.next_offset ?? []);
12822
+ if (!offsets.length)
12823
+ break;
12824
+ const offset = Math.min(...offsets);
12825
+ if (visited.has(offset))
12826
+ throw new UsageError([READ_USAGE.paging]);
12827
+ visited.add(offset);
12828
+ response = await compareRuns(target, result.a.id, result.b.id, { component_offset: offset, component_limit: size });
12829
+ for (const w of result.windows) {
12830
+ const c = w.component_page;
12831
+ if (!c && !w.components)
12832
+ continue;
12833
+ const more = response.windows.find((x) => x.window === w.window)?.component_page;
12834
+ if (!c || !more)
12835
+ throw new UsageError([READ_USAGE.paging]);
12836
+ const keys = new Set(c.rows.map((row) => row.key));
12837
+ c.rows.push(...more.rows.filter((row) => !keys.has(row.key)));
12838
+ for (const field of ["added", "removed"]) {
12839
+ const existing = c[field] ?? [];
12840
+ const known = new Set(existing.map((row) => row.key));
12841
+ c[field] = [...existing, ...(more[field] ?? []).filter((row) => !known.has(row.key))];
12842
+ }
12843
+ for (const side of ["a", "b"]) {
12844
+ if (!c.uncompared || !more.uncompared)
12845
+ continue;
12846
+ const known = new Set(c.uncompared[side].map((row) => row.key));
12847
+ c.uncompared[side].push(...more.uncompared[side].filter((row) => !known.has(row.key)));
12848
+ }
12849
+ c.page = more.page;
12850
+ }
12851
+ }
12852
+ for (const w of result.windows) {
12853
+ const c = w.component_page;
12854
+ if (!c)
12855
+ continue;
12856
+ const count = c.rows.length + (c.added?.length ?? 0) + (c.removed?.length ?? 0) + (c.uncompared?.a.length ?? 0) + (c.uncompared?.b.length ?? 0);
12857
+ if (count !== c.page.total)
12858
+ throw new UsageError([READ_USAGE.paging]);
12859
+ c.page = { ...c.page, offset: 0, limit: count, next_offset: null };
12860
+ }
12861
+ return result;
12862
+ }
11324
12863
 
11325
12864
  // apps/agent/src/cli.ts
11326
12865
  var USAGE = `keepsnappy <command>
@@ -11328,21 +12867,26 @@ var USAGE = `keepsnappy <command>
11328
12867
  [--upload [--wait] [--fail-on-worse] [--ingest-url URL] [--commit SHA] [--branch NAME] [--base-commit SHA]]
11329
12868
  [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
11330
12869
  [--no-markers] [-- <e2e command...>]
12870
+ (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
12871
+ so with no computed verdict it checks nothing: "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing", exit 0)
11331
12872
  measure --repeat N [--arms A=<app|bundle>,B=<app|bundle>] [--arm-env 'A:KEY=VAL' ...] [--bundle-id ID] [--platform ios-sim|ios-device]
11332
12873
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
11333
12874
  [--clock-offset-ms N] [--no-markers]
11334
12875
  -- <test command...>
11335
- farm open --api URL --device-model NAME [--device-os V] [--device-id SERIAL|UDID] [--device-provider browserstack] [--platform ios|android] --journeys a,b [--repeat 5]
12876
+ 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]
12877
+ (--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
12878
  --tokens-js <file> --state <file> [--branch NAME --commit SHA --base-commit SHA] [--ttl-seconds 21600] [--key-env KEEPSNAPPY_KEY]
11337
12879
  [--config keepsnappy.yml] [--flows <files|dirs|globs,...>] (journey titles: keepsnappy.yml flows.paths, else the flow's name: / top comment)
11338
12880
  [--completion-marker] (the flow posts step run:complete at the end of each iteration; runs without it are invalid)
11339
12881
  [--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
12882
  [--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
11341
12883
  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]
12884
+ (--fail-on-worse as for run: with no verdict computed on the run set it checks nothing)
12885
+ 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
12886
  [--tunnel-for localhost:PORT ...] [--env KEY=VALUE ...] (values may use {tunnel:PORT} {run_id} {out} {tokens_js} {run_set_id})
11344
12887
  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 ...]
12888
+ appium: --journey-module FILE | --driver-script FILE [--driver-arg ARG ...] (iOS and Android)
12889
+ [--timeout-minutes 80] [--kill-after-seconds 120] (Appium: SIGTERM at timeout, then SIGKILL after grace)
11346
12890
  [--branch NAME --commit SHA] [--wait [--fail-on-worse]] [--out DIR] [--dry-run] (open + tunnel + device + close; docs/plans/54a)
11347
12891
  extract <out-dir> --ri-capture observations.ndjson [--mem-window NAME]
11348
12892
  aa <out-dir> [--reps N] [--boot N]
@@ -11380,73 +12924,80 @@ async function main2(argv) {
11380
12924
  if (cmd && LINK_COMMANDS.has(cmd))
11381
12925
  return linksCommand(cmd, rest, { print: console.log });
11382
12926
  const deps = nodeDeps();
11383
- const { values: o, positionals } = parseArgs4({
11384
- args: rest,
11385
- allowPositionals: true,
11386
- options: {
11387
- repeat: { type: "string" },
11388
- arms: { type: "string" },
11389
- "arm-env": { type: "string", multiple: true },
11390
- platform: { type: "string" },
11391
- device: { type: "string" },
11392
- out: { type: "string" },
11393
- "bundle-id": { type: "string" },
11394
- timeout: { type: "string" },
11395
- reps: { type: "string" },
11396
- boot: { type: "string" },
11397
- "ri-capture": { type: "string" },
11398
- "mem-window": { type: "string" },
11399
- "clock-offset-ms": { type: "string" },
11400
- "no-markers": { type: "boolean" },
11401
- config: { type: "string" },
11402
- upload: { type: "boolean" },
11403
- "ingest-url": { type: "string" },
11404
- commit: { type: "string" },
11405
- branch: { type: "string" },
11406
- "base-commit": { type: "string" },
11407
- wait: { type: "boolean" },
11408
- "fail-on-worse": { type: "boolean" },
11409
- scenario: { type: "string" },
11410
- "device-model": { type: "string" },
11411
- "device-os": { type: "string" },
11412
- "device-id": { type: "string" },
11413
- replay: { type: "string", multiple: true },
11414
- api: { type: "string" },
11415
- "key-env": { type: "string" },
11416
- "device-provider": { type: "string" },
11417
- journeys: { type: "string" },
11418
- "tokens-js": { type: "string" },
11419
- state: { type: "string" },
11420
- "ttl-seconds": { type: "string" },
11421
- "bs-build": { type: "string" },
11422
- "settle-seconds": { type: "string" },
11423
- "max-wait-seconds": { type: "string" },
11424
- flows: { type: "string" },
11425
- "completion-marker": { type: "boolean" },
11426
- "driver-version": { type: "string" },
11427
- "app-build-id": { type: "string" },
11428
- "harness-version": { type: "string" },
11429
- "journey-version": { type: "string" },
11430
- "flag-set": { type: "string" },
11431
- app: { type: "string" },
11432
- driver: { type: "string" },
11433
- "tunnel-for": { type: "string", multiple: true },
11434
- env: { type: "string", multiple: true },
11435
- "driver-script": { type: "string" },
11436
- "journey-module": { type: "string" },
11437
- "driver-arg": { type: "string", multiple: true },
11438
- "suite-dir": { type: "string" },
11439
- execute: { type: "string" },
11440
- "tokens-path": { type: "string" },
11441
- "maestro-version": { type: "string" },
11442
- "timeout-minutes": { type: "string" },
11443
- "poll-seconds": { type: "string" },
11444
- "custom-id": { type: "string" },
11445
- "run-id": { type: "string" },
11446
- "dry-run": { type: "boolean" },
11447
- "sdk-args": { type: "string" }
12927
+ const { values: o, positionals } = (() => {
12928
+ try {
12929
+ return parseArgs4({
12930
+ args: rest,
12931
+ allowPositionals: true,
12932
+ options: {
12933
+ repeat: { type: "string" },
12934
+ arms: { type: "string" },
12935
+ "arm-env": { type: "string", multiple: true },
12936
+ platform: { type: "string" },
12937
+ device: { type: "string" },
12938
+ out: { type: "string" },
12939
+ "bundle-id": { type: "string" },
12940
+ timeout: { type: "string" },
12941
+ reps: { type: "string" },
12942
+ boot: { type: "string" },
12943
+ "ri-capture": { type: "string" },
12944
+ "mem-window": { type: "string" },
12945
+ "clock-offset-ms": { type: "string" },
12946
+ "no-markers": { type: "boolean" },
12947
+ config: { type: "string" },
12948
+ upload: { type: "boolean" },
12949
+ "ingest-url": { type: "string" },
12950
+ commit: { type: "string" },
12951
+ branch: { type: "string" },
12952
+ "base-commit": { type: "string" },
12953
+ wait: { type: "boolean" },
12954
+ "fail-on-worse": { type: "boolean" },
12955
+ scenario: { type: "string" },
12956
+ "device-model": { type: "string" },
12957
+ "device-os": { type: "string" },
12958
+ "device-id": { type: "string" },
12959
+ replay: { type: "string", multiple: true },
12960
+ api: { type: "string" },
12961
+ "key-env": { type: "string" },
12962
+ "device-provider": { type: "string" },
12963
+ journeys: { type: "string" },
12964
+ "tokens-js": { type: "string" },
12965
+ state: { type: "string" },
12966
+ "ttl-seconds": { type: "string" },
12967
+ "bs-build": { type: "string" },
12968
+ "settle-seconds": { type: "string" },
12969
+ "max-wait-seconds": { type: "string" },
12970
+ flows: { type: "string" },
12971
+ "completion-marker": { type: "boolean" },
12972
+ "driver-version": { type: "string" },
12973
+ "app-build-id": { type: "string" },
12974
+ "harness-version": { type: "string" },
12975
+ "journey-version": { type: "string" },
12976
+ "flag-set": { type: "string" },
12977
+ app: { type: "string" },
12978
+ driver: { type: "string" },
12979
+ "tunnel-for": { type: "string", multiple: true },
12980
+ env: { type: "string", multiple: true },
12981
+ "driver-script": { type: "string" },
12982
+ "journey-module": { type: "string" },
12983
+ "driver-arg": { type: "string", multiple: true },
12984
+ "suite-dir": { type: "string" },
12985
+ execute: { type: "string" },
12986
+ "tokens-path": { type: "string" },
12987
+ "maestro-version": { type: "string" },
12988
+ "timeout-minutes": { type: "string" },
12989
+ "kill-after-seconds": { type: "string" },
12990
+ "poll-seconds": { type: "string" },
12991
+ "custom-id": { type: "string" },
12992
+ "run-id": { type: "string" },
12993
+ "dry-run": { type: "boolean" },
12994
+ "sdk-args": { type: "string" }
12995
+ }
12996
+ });
12997
+ } catch (e) {
12998
+ throw new UsageError([e.message, "keepsnappy --help lists the commands and their flags"]);
11448
12999
  }
11449
- });
13000
+ })();
11450
13001
  if (cmd === "doctor") {
11451
13002
  console.log(formatChecks(await doctor(deps.spawner, process.version)));
11452
13003
  return 0;
@@ -11466,16 +13017,16 @@ async function main2(argv) {
11466
13017
  if (cmd === "measure") {
11467
13018
  const repeat = Number(o.repeat);
11468
13019
  if (!Number.isInteger(repeat) || repeat < 1)
11469
- throw new Error("--repeat N (>= 1) is required");
13020
+ throw new ConfigError(["--repeat N (>= 1) is required"]);
11470
13021
  const platform = o.platform ?? "ios-sim";
11471
13022
  if (platform !== "ios-sim" && platform !== "ios-device")
11472
- throw new Error(`unknown --platform ${platform}`);
13023
+ throw new ConfigError([`unknown --platform ${platform}`]);
11473
13024
  if (platform === "ios-device" && !o.device)
11474
- throw new Error("--device <udid> is required for ios-device");
13025
+ throw new ConfigError(["--device <udid> is required for ios-device"]);
11475
13026
  const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
11476
- const out = resolve7(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
13027
+ const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
11477
13028
  const udid = o.device ?? "booted";
11478
- const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve7(out, ".scratch"));
13029
+ const source = platform === "ios-sim" ? new IosSimulatorDataSource(deps, udid) : new IosDeviceDataSource(deps, udid, resolve8(out, ".scratch"));
11479
13030
  const installer = platform === "ios-sim" ? new IosSimulatorInstaller(deps, udid) : new IosDeviceInstaller(deps, udid);
11480
13031
  const bundleId = await bundleIdOf(deps, arms[0]?.target, o["bundle-id"]);
11481
13032
  let markers;
@@ -11494,7 +13045,7 @@ async function main2(argv) {
11494
13045
  outDir: out,
11495
13046
  testCommand: positionals,
11496
13047
  bundleId,
11497
- ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve7(o["ri-capture"])) } : {},
13048
+ ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve8(o["ri-capture"])) } : {},
11498
13049
  ...o["mem-window"] ? { memWindow: o["mem-window"] } : {},
11499
13050
  ...o.timeout ? { testTimeoutMs: Number(o.timeout) * 1000 } : {}
11500
13051
  });
@@ -11505,18 +13056,18 @@ async function main2(argv) {
11505
13056
  if (cmd === "extract") {
11506
13057
  const dir = positionals[0];
11507
13058
  if (!dir)
11508
- throw new Error("extract needs <out-dir>");
11509
- const ri = o["ri-capture"] ? new FileRiSource(deps, resolve7(o["ri-capture"])) : undefined;
11510
- const rows = await extract(deps, resolve7(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
11511
- console.log(`${rows.length} rows -> ${resolve7(dir, "metrics.csv")}`);
13059
+ throw new ConfigError(["extract needs <out-dir>"]);
13060
+ const ri = o["ri-capture"] ? new FileRiSource(deps, resolve8(o["ri-capture"])) : undefined;
13061
+ const rows = await extract(deps, resolve8(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
13062
+ console.log(`${rows.length} rows -> ${resolve8(dir, "metrics.csv")}`);
11512
13063
  return 0;
11513
13064
  }
11514
13065
  if (cmd === "aa") {
11515
13066
  const dir = positionals[0];
11516
13067
  if (!dir)
11517
- throw new Error("aa needs <out-dir>");
13068
+ throw new ConfigError(["aa needs <out-dir>"]);
11518
13069
  const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
11519
- console.log((await aa(deps, resolve7(dir), { config })).table);
13070
+ console.log((await aa(deps, resolve8(dir), { config })).table);
11520
13071
  return 0;
11521
13072
  }
11522
13073
  console.error(USAGE);