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

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