@keepsnappy/agent 0.1.0-alpha.14 → 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",
@@ -5063,7 +5273,10 @@ var STATE_WORDS = {
5063
5273
  "no-change": "No change",
5064
5274
  "cant-tell": "Can’t tell"
5065
5275
  };
5276
+ var INFO_WORDS = { heat: "Phone heated up" };
5277
+ var isWorseState = (state) => state === "worse";
5066
5278
  var NOT_BLOCKING = "not blocking";
5279
+
5067
5280
  // packages/present/dist/src/values.js
5068
5281
  var NO_VALUE = "–";
5069
5282
  var FALLBACK_DECIMALS = 1;
@@ -5119,6 +5332,143 @@ function fieldValueText(field, v) {
5119
5332
  const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
5120
5333
  return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
5121
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`;
5122
5472
  // packages/present/dist/src/stability.js
5123
5473
  var UNSTABLE_WORDS = {
5124
5474
  short: "main varies too much here",
@@ -5127,6 +5477,92 @@ var UNSTABLE_WORDS = {
5127
5477
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5128
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."
5129
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
+ }
5130
5566
  // apps/agent/src/summary.ts
5131
5567
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5132
5568
  function plainMetric(metric) {
@@ -5166,6 +5602,13 @@ function summarize(input) {
5166
5602
  // apps/agent/src/remote.ts
5167
5603
  var isBlocking = (s) => !!s?.verdicts && (s.verdicts.blocking ?? s.verdicts.state === "worse");
5168
5604
  var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
5605
+ function heatNote(sc) {
5606
+ const n = sc.heated_runs ?? 0;
5607
+ if (!n)
5608
+ return null;
5609
+ const phone = sc.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
5610
+ return `${INFO_WORDS.heat} in ${n} of ${sc.runs.valid + sc.runs.invalid} runs · ${sc.scenario ?? "journey"} · ${phone}`;
5611
+ }
5169
5612
  function movedItems(v, sc) {
5170
5613
  const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
5171
5614
  const h = sc.headline;
@@ -5201,7 +5644,13 @@ function movedCounts(sc) {
5201
5644
  }
5202
5645
  var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5203
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 });
5204
- 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
+ };
5205
5654
  var keyHeader = (t) => ({ "x-keepsnappy-key": t.key });
5206
5655
  async function createRunSet(t, body) {
5207
5656
  const r = await call(t.fetch ?? fetch, t.url, "/v1/run-sets", keyHeader(t), body);
@@ -5263,11 +5712,16 @@ function formatRunSet(s, top = 5) {
5263
5712
  lines.push(` ${FEW_RUNS}`);
5264
5713
  if (sc.role !== "adhoc" && !noCounts(movedCounts(sc)))
5265
5714
  lines.push(` ${countsLine(movedCounts(sc))}`);
5266
- const moved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn")).slice(0, top);
5715
+ if (heatNote(sc))
5716
+ lines.push(` ${heatNote(sc)}`);
5717
+ const allMoved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn"));
5718
+ const moved = allMoved.slice(0, top);
5267
5719
  for (const i of moved) {
5268
5720
  const what = i.label_text ? `${i.step && i.step !== "*" ? `${placeLabel(i.step)}: ` : ""}${i.label_text}` : i.step ? `${placeLabel(i.step)}: ${fieldWord(i.metric)}` : plainMetric(i.metric);
5269
5721
  lines.push(` ${stateWord(i.state === "worse" && i.grade === "warn" ? "warn" : i.state).padEnd(WORD_WIDTH)} ${what}${i.change_text ? `: ${i.change_text}` : change(i.metric, i.baseline, i.candidate)}`);
5270
5722
  }
5723
+ if (allMoved.length > moved.length)
5724
+ lines.push(` ${allMoved.length - moved.length} more`);
5271
5725
  if (sc.dashboard_url)
5272
5726
  lines.push(` ${sc.dashboard_url}`);
5273
5727
  }
@@ -5288,6 +5742,7 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5288
5742
  `;
5289
5743
  }
5290
5744
  const v = s.verdicts;
5745
+ const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5291
5746
  const rows = v.scenarios.map((sc) => {
5292
5747
  const c = movedCounts(sc);
5293
5748
  return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${word(sc.state, sc.word, sc.headline)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
@@ -5301,6 +5756,7 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5301
5756
  `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5302
5757
  "|---|---|---|---|---|",
5303
5758
  ...rows,
5759
+ ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5304
5760
  "",
5305
5761
  `[Open the dashboard](${s.dashboard_url})`,
5306
5762
  ""
@@ -8812,15 +9268,18 @@ ${r.stderr}` : ""}`);
8812
9268
  }
8813
9269
 
8814
9270
  // apps/agent/src/run.ts
8815
- 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
+ }
8816
9277
  const pairs = Object.entries(rename).flatMap(([a, b]) => [[a, b], [`step:${a}`, `step:${b}`]]);
8817
9278
  if (pairs.length === 0)
8818
9279
  return [...rows];
8819
9280
  return rows.map((r) => {
8820
9281
  for (const [from, to] of pairs) {
8821
- if (r.metric === from)
8822
- return { ...r, metric: to };
8823
- if (r.metric.startsWith(`${from}.`))
9282
+ if (matchesMetric(from, r.metric))
8824
9283
  return { ...r, metric: `${to}${r.metric.slice(from.length)}` };
8825
9284
  }
8826
9285
  return r;
@@ -8838,7 +9297,7 @@ async function keepsnappyRun(deps, source, opts) {
8838
9297
  ...opts.markers ? { markers: opts.markers } : {},
8839
9298
  ...opts.clockOffsetMs === undefined ? {} : { clockOffsetMs: opts.clockOffsetMs }
8840
9299
  });
8841
- const rows = renameMetrics(raw, opts.config.steps.rename);
9300
+ const rows = renameMetrics(raw, opts.config.steps.rename, deps.log);
8842
9301
  await deps.fs.writeFile(join8(opts.outDir, "metrics.csv"), toCsv(rows));
8843
9302
  const summary = summarize({ runs, outDir: opts.outDir });
8844
9303
  return { runs, rows, summary };
@@ -9257,10 +9716,20 @@ var nodeSpawner = {
9257
9716
  let stdout = "";
9258
9717
  let stderr = "";
9259
9718
  let error;
9719
+ let timeout;
9720
+ let killTimeout;
9721
+ let closed = false;
9260
9722
  const child = spawn(cmd, [...args], {
9261
- env: { ...process.env, ...opts?.env },
9262
- ...opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}
9723
+ env: { ...process.env, ...opts?.env }
9263
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
+ }
9264
9733
  child.stdout.on("data", (d) => {
9265
9734
  stdout += d.toString();
9266
9735
  opts?.onOutput?.(d.toString());
@@ -9271,8 +9740,11 @@ var nodeSpawner = {
9271
9740
  });
9272
9741
  child.on("error", (e) => error = e.message);
9273
9742
  child.on("close", (code, signal) => {
9274
- if (signal && !error)
9275
- 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}`;
9276
9748
  resolve({ code, stdout, stderr, ...error ? { error } : {} });
9277
9749
  });
9278
9750
  })
@@ -9315,7 +9787,7 @@ async function aa(deps, outDir, opts = {}) {
9315
9787
  }
9316
9788
 
9317
9789
  // apps/agent/src/farm.ts
9318
- 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";
9319
9791
 
9320
9792
  // packages/appium-driver/dist/src/links.js
9321
9793
  var ALERT_POLL_MS = 250;
@@ -9548,6 +10020,7 @@ var ANDROID = {
9548
10020
  "appium:autoGrantPermissions": true,
9549
10021
  "appium:newCommandTimeout": NEW_COMMAND_TIMEOUT_S
9550
10022
  }),
10023
+ absentAttributeText: "null",
9551
10024
  settings: { waitForIdleTimeout: NO_WAIT_MS, actionAcknowledgmentTimeout: NO_WAIT_MS, waitForSelectorTimeout: NO_WAIT_MS, enableMultiWindows: true, ignoreUnimportantViews: true },
9552
10025
  locators: androidLocators,
9553
10026
  appArg: (appId) => ({ appId }),
@@ -9612,18 +10085,13 @@ class WdError extends Error {
9612
10085
  this.wdError = wdError;
9613
10086
  }
9614
10087
  }
9615
- // packages/appium-driver/dist/src/session.js
9616
- var SDK_FLUSH_WORST_CASE_MS = 315000;
9617
- var FLUSH_MARGIN_MS = 30000;
9618
- var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
9619
- var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
9620
10088
  // packages/appium-driver/dist/src/version.js
9621
10089
  import { createHash } from "node:crypto";
9622
10090
  import { existsSync, readFileSync, statSync } from "node:fs";
9623
10091
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
9624
10092
  import { fileURLToPath } from "node:url";
9625
10093
  function driverVersion() {
9626
- const built = "0.1.0-alpha.14";
10094
+ const built = "0.1.0-alpha.16";
9627
10095
  if (built)
9628
10096
  return built;
9629
10097
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -9672,8 +10140,93 @@ function journeyVersion(modulePath) {
9672
10140
  hash.update(key).update("\x00").update(text).update("\x00");
9673
10141
  return hash.digest("hex").slice(0, 12);
9674
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
+
9675
10228
  // apps/agent/src/slack.ts
9676
- var COLOR = { error: "#E01E5A", warn: "#ECB22E", better: "#2EB67D", none: "#9AA0A6" };
10229
+ var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
9677
10230
  var MAX_ROWS = 3;
9678
10231
  var NAME_W = 30;
9679
10232
  var COLUMN_GAP = 1;
@@ -9685,8 +10238,6 @@ function metricWord(item) {
9685
10238
  return measureDef(item.metric)?.short ?? item.metric.replace(/_/g, " ");
9686
10239
  }
9687
10240
  function change2(item) {
9688
- if (item.metric === HEAT_METRIC && item.baseline !== null && item.candidate !== null)
9689
- return `+${Math.round((item.candidate - item.baseline) * 100)} pp`;
9690
10241
  if (item.change_text)
9691
10242
  return item.change_text.replace(/ \(.*\)$/, "");
9692
10243
  const { baseline: b, candidate: c } = item;
@@ -9699,13 +10250,13 @@ function change2(item) {
9699
10250
  }
9700
10251
  var size = (i) => i.baseline === null || i.candidate === null ? 0 : i.baseline === 0 ? i.candidate === 0 ? 0 : Number.POSITIVE_INFINITY : Math.abs(Math.log(Math.max(i.candidate, 0.000000001) / i.baseline));
9701
10252
  var bySize = (a, b) => size(a) === size(b) ? 0 : size(a) > size(b) ? -1 : 1;
9702
- var worseOrder = (a, b) => Number(a.grade === "warn") - Number(b.grade === "warn") || Number(b.metric === HEAT_METRIC) - Number(a.metric === HEAT_METRIC) || bySize(a, b);
10253
+ var worseOrder = (a, b) => Number(a.grade === "warn") - Number(b.grade === "warn") || bySize(a, b);
9703
10254
  var isWorse = (i) => i.state === "worse";
9704
10255
  var isBetter = (i) => i.state === "better";
9705
10256
  var differs = (sc) => sc?.headline?.measured_differently ?? [];
9706
10257
  var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
9707
10258
  var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
9708
- 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";
9709
10260
  var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
9710
10261
  var right = (s, w) => s.padStart(w);
9711
10262
  var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
@@ -9765,19 +10316,28 @@ function slackMessage(s, ctx) {
9765
10316
  const worse = items.filter(isWorse).sort(worseOrder);
9766
10317
  const better = items.filter(isBetter).sort(bySize);
9767
10318
  const commit = commitLine(ctx);
10319
+ const heated = v.scenarios.filter((sc) => heatNote(sc));
10320
+ const heat = heated.map((sc) => context(heatNote(sc)));
9768
10321
  const errorRows = worse.filter((i) => i.grade !== "warn");
9769
10322
  const warningRows = worse.filter((i) => i.grade === "warn");
9770
10323
  const errors = placeCount(errorRows);
9771
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;
9772
10329
  if (isPr) {
9773
- if (!worse.length)
9774
- return null;
9775
- const red = worse.some((i) => isRed(i, scenarioOf(i)));
10330
+ if (!worse.length) {
10331
+ if (!heat.length)
10332
+ return null;
10333
+ const head = `⚪ ${ctx.branch}: ${INFO_WORDS.heat}`;
10334
+ const blocks = [section(`*${head}*`), ...heat, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0].dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10335
+ return { text: head, attachments: [{ color: COLOR.none, blocks }] };
10336
+ }
9776
10337
  const graded = worse.some((i) => i.grade);
9777
- const notBlocking = errors > 0 && !red;
9778
10338
  const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
9779
- 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));
9780
- 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}`;
9781
10341
  const blocks = [section(`*${head}*`)];
9782
10342
  const groups = new Map;
9783
10343
  for (const i of worse)
@@ -9793,21 +10353,24 @@ function slackMessage(s, ctx) {
9793
10353
  const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
9794
10354
  const note = differs(sc).length ? `
9795
10355
  _Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
9796
- 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}
9797
10358
  ${prTable(rows, sc)}`));
9798
10359
  }
9799
10360
  const more = worse.length - shown;
10361
+ blocks.push(...heat);
9800
10362
  if (commit)
9801
10363
  blocks.push(context(commit));
9802
10364
  blocks.push(section(`\uD83D\uDC49 <${groups.keys().next().value?.dashboard_url ?? s.dashboard_url}|View the comparison>${more > 0 ? ` · ${more} more` : ""}`));
9803
- return { text: head, attachments: [{ color: red ? COLOR.error : COLOR.warn, blocks }] };
10365
+ return { text: head, attachments: [{ color: worseColor, blocks }] };
9804
10366
  }
9805
- if (!worse.length && !better.length && !ctx.always)
10367
+ const improved = better.length > 0 || v.state === "better";
10368
+ if (!worse.length && !improved && !ctx.always && !heat.length)
9806
10369
  return null;
9807
- const parts = [errors ? `${errors} ${STATE_WORDS.worse.toLowerCase()}` : "", warns ? `${warns} ${STATE_WORDS.warning.toLowerCase()}` : "", better.length ? `${placeCount(better)} ${STATE_WORDS.better.toLowerCase()}` : ""].filter(Boolean);
9808
- const quiet = (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
9809
- const status = parts.length ? `${ctx.branch}: ${parts.join(", ")}` : quiet.startsWith("Main: ") && ctx.branch === "main" ? quiet : `${ctx.branch}: ${quiet}`;
9810
- const head = `${worse.length ? worse.some((i) => isRed(i, scenarioOf(i))) ? "\uD83D\uDD34" : "\uD83D\uDFE0" : better.length ? "\uD83D\uDFE2" : "⚪"} ${status}`;
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);
10371
+ const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
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}`;
10373
+ const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
9811
10374
  const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
9812
10375
  const blocks = [section(`*${head}*
9813
10376
  ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
@@ -9818,15 +10381,16 @@ ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
9818
10381
  break;
9819
10382
  shown++;
9820
10383
  const sc = scenarioOf(i);
9821
- 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)}
9822
10385
  ${mainCard(i)}`));
9823
10386
  }
9824
10387
  }
9825
10388
  const more = worse.length + better.length - shown;
10389
+ blocks.push(...heat);
9826
10390
  if (commit)
9827
10391
  blocks.push(context(commit));
9828
10392
  blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
9829
- const color = worse.length ? worse.some((i) => isRed(i, scenarioOf(i))) ? COLOR.error : COLOR.warn : better.length ? COLOR.better : COLOR.none;
10393
+ const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
9830
10394
  return { text: head, attachments: [{ color, blocks }] };
9831
10395
  }
9832
10396
  async function postToSlack(url, payload, fetchImpl = fetch) {
@@ -9973,9 +10537,20 @@ async function farmOpen(deps, f, rt) {
9973
10537
  const git = gitContext({ ...f.branch ? { branch: f.branch } : {}, ...f.commit ? { commit: f.commit } : {} }, rt.env);
9974
10538
  if (!git.branch)
9975
10539
  problems.push("--branch is required outside GitHub Actions");
9976
- 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
+ }
9977
10552
  if (!model)
9978
- 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")');
9979
10554
  if (f.platform !== undefined && !["ios", "android", "web"].includes(f.platform))
9980
10555
  problems.push("--platform must be ios, android or web");
9981
10556
  const journeys = (f.journeys ?? "").split(",").map((j) => j.trim()).filter(Boolean);
@@ -10005,7 +10580,7 @@ async function farmOpen(deps, f, rt) {
10005
10580
  }
10006
10581
  const api = apiTarget(url, f, rt);
10007
10582
  const now = rt.now ?? Date.now;
10008
- const device = { model, os: f["device-os"] ?? null, provider: f["device-provider"] ?? "browserstack" };
10583
+ const device = { model, os: f["device-os"] ?? null, provider };
10009
10584
  const journeyInfo = await farmJourneys(deps, f, rt, journeys);
10010
10585
  const withInstrument = { ...f, ...instrumentFlags(f) };
10011
10586
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
@@ -10263,7 +10838,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
10263
10838
  await deps.fs.writeFile(statePath, `${JSON.stringify(state2, null, 2)}
10264
10839
  `);
10265
10840
  await deps.fs.mkdir(out);
10266
- 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)}
10267
10842
  `);
10268
10843
  await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
10269
10844
  const step = rt.env.GITHUB_STEP_SUMMARY;
@@ -10309,11 +10884,11 @@ ${tail.join(" ")}
10309
10884
  // apps/agent/src/farm-run.ts
10310
10885
  import { existsSync as existsSync2 } from "node:fs";
10311
10886
  import { createRequire } from "node:module";
10312
- 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";
10313
10888
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10314
10889
 
10315
10890
  // apps/agent/src/app-source.ts
10316
- import { join as join13 } from "node:path";
10891
+ import { join as join14 } from "node:path";
10317
10892
  var EAS_CLI_VERSION = "24.6.0";
10318
10893
  var isEas = (spec) => spec.startsWith("eas:");
10319
10894
 
@@ -10325,7 +10900,7 @@ class DefaultAppResolver {
10325
10900
  this.env = env;
10326
10901
  }
10327
10902
  async resolve(spec, o) {
10328
- if (spec.startsWith("bs://"))
10903
+ if (spec.startsWith("bs://") || spec.startsWith("custom_id:"))
10329
10904
  return { app: spec, commit: null };
10330
10905
  if (!isEas(spec)) {
10331
10906
  if (!await this.deps.fs.exists(spec))
@@ -10349,7 +10924,7 @@ class DefaultAppResolver {
10349
10924
  const url = build.artifacts?.buildUrl ?? build.artifacts?.applicationArchiveUrl;
10350
10925
  if (!url)
10351
10926
  throw new Error(`EAS build ${id} has no downloadable artifact (is it finished?)`);
10352
- 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"}`);
10353
10928
  await this.deps.fs.mkdir(o.outDir);
10354
10929
  const dl = await this.deps.spawner.run("curl", ["-fsSL", "-o", file, url]);
10355
10930
  if (dl.code !== 0)
@@ -10360,7 +10935,7 @@ class DefaultAppResolver {
10360
10935
 
10361
10936
  // apps/agent/src/browserstack.ts
10362
10937
  import { randomUUID } from "node:crypto";
10363
- import { basename as basename2, join as join14 } from "node:path";
10938
+ import { basename as basename2, join as join15 } from "node:path";
10364
10939
  import { readFile as readFile3 } from "node:fs/promises";
10365
10940
  var BASE = "https://api-cloud.browserstack.com/app-automate/maestro/v2";
10366
10941
  var TERMINAL = new Set(["passed", "failed", "error", "timedout", "skipped", "stopped"]);
@@ -10431,7 +11006,7 @@ class BrowserStack {
10431
11006
  }
10432
11007
  }
10433
11008
  async uploadApp(app, customId) {
10434
- if (app.startsWith("bs://"))
11009
+ if (app.startsWith("bs://") || app.startsWith("custom_id:"))
10435
11010
  return app;
10436
11011
  const form = new FormData;
10437
11012
  form.append("file", new Blob([await this.bytes(app)]), basename2(app));
@@ -10444,7 +11019,7 @@ class BrowserStack {
10444
11019
  }
10445
11020
  async runMaestro(b) {
10446
11021
  const appUrl = await this.uploadApp(b.app, b.customId);
10447
- 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)()}`);
10448
11023
  let suiteUrl;
10449
11024
  try {
10450
11025
  const zip = await this.zipSuite(b, work);
@@ -10477,17 +11052,17 @@ class BrowserStack {
10477
11052
  return this.poll(buildId, b);
10478
11053
  }
10479
11054
  async zipSuite(b, work) {
10480
- const zipPath = join14(work, "suite.zip");
10481
- const stage = join14(work, "stage");
11055
+ const zipPath = join15(work, "suite.zip");
11056
+ const stage = join15(work, "stage");
10482
11057
  await this.deps.fs.mkdir(stage);
10483
11058
  const cp = await this.deps.spawner.run("cp", ["-R", b.suiteDir.replace(/\/$/, ""), stage]);
10484
11059
  if (cp.code !== 0)
10485
11060
  throw new FarmApiError(`cannot copy the suite ${b.suiteDir}: ${cp.stderr || cp.error}`);
10486
11061
  const folder = basename2(b.suiteDir.replace(/\/$/, ""));
10487
- if (!await this.deps.fs.exists(join14(stage, folder, "config.yaml")))
11062
+ if (!await this.deps.fs.exists(join15(stage, folder, "config.yaml")))
10488
11063
  throw new FarmApiError(`${b.suiteDir} has no config.yaml: is that the Maestro workspace?`);
10489
- const target = join14(stage, folder, b.tokensPath);
10490
- await this.deps.fs.mkdir(join14(target, ".."));
11064
+ const target = join15(stage, folder, b.tokensPath);
11065
+ await this.deps.fs.mkdir(join15(target, ".."));
10491
11066
  await this.deps.fs.writeFile(target, await this.deps.fs.readFile(b.tokensJs));
10492
11067
  const include = SUITE_EXTENSIONS.flatMap((e) => ["-i", `${folder}/*${e}`, "-i", `${folder}/**/*${e}`]);
10493
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/*"]);
@@ -10546,9 +11121,9 @@ class BrowserStackLocal {
10546
11121
  const url = TUNNEL_ZIP[this.o.platform ?? process.platform];
10547
11122
  if (!url)
10548
11123
  throw new FarmApiError(`no BrowserStackLocal binary known for ${this.o.platform ?? process.platform}`);
10549
- const dir = join14(this.o.dir, identifier);
10550
- const bin = join14(dir, "BrowserStackLocal");
10551
- 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");
10552
11127
  await this.deps.fs.mkdir(dir);
10553
11128
  for (const [cmd, args] of [
10554
11129
  ["curl", ["-fsSL", "-o", zip, url]],
@@ -10582,12 +11157,12 @@ ${r.stderr}`;
10582
11157
  // apps/agent/src/farm-run.ts
10583
11158
  function appiumDriverEntry(o = {}) {
10584
11159
  const exists = o.exists ?? existsSync2;
10585
- 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");
10586
11161
  if (exists(bundled))
10587
11162
  return bundled;
10588
11163
  const pkgDir = dirname4(o.packageJson ? o.packageJson() : createRequire(import.meta.url).resolve("@keepsnappy/appium-driver/package.json"));
10589
- const built = join15(pkgDir, "dist", "src", "cli.js");
10590
- 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");
10591
11166
  }
10592
11167
  var driverRuntime = (entry) => entry.endsWith(".ts") ? process.execPath : "node";
10593
11168
  function nodeFarmRunPorts(deps, rt) {
@@ -10596,7 +11171,7 @@ function nodeFarmRunPorts(deps, rt) {
10596
11171
  if (!user || !key)
10597
11172
  throw new ConfigError(["BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY must be set"]);
10598
11173
  const tmp = rt.env.RUNNER_TEMP ?? rt.env.TMPDIR ?? "/tmp";
10599
- const dir = join15(tmp, "keepsnappy-bs-local");
11174
+ const dir = join16(tmp, "keepsnappy-bs-local");
10600
11175
  return {
10601
11176
  apps: new DefaultAppResolver(deps, rt.env),
10602
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 } : {} }),
@@ -10612,7 +11187,7 @@ var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
10612
11187
  function planFarmRun(f) {
10613
11188
  const problems = [];
10614
11189
  if (!f.app)
10615
- 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");
10616
11191
  const platform = f.platform ?? "ios";
10617
11192
  if (platform !== "ios" && platform !== "android")
10618
11193
  problems.push("--platform must be ios or android");
@@ -10658,14 +11233,15 @@ function planFarmRun(f) {
10658
11233
  problems.push("--driver-script and --journey-module are two ways to run Appium; pass one");
10659
11234
  } else if (!f["driver-script"] && !f["journey-module"]) {
10660
11235
  problems.push("--driver appium needs --journey-module <file> (the built-in driver) or --driver-script <file> (your own driver)");
10661
- } else if (f["journey-module"] && platform !== "ios") {
10662
- problems.push("--journey-module: the built-in Appium driver drives iOS only");
10663
11236
  }
10664
11237
  if (f["run-id"] !== undefined && !RUN_ID.test(f["run-id"]))
10665
11238
  problems.push("--run-id: letters, digits, . _ - only (it names the tunnel and its folder)");
10666
11239
  if (f["fail-on-worse"] && !f.wait)
10667
11240
  problems.push("--fail-on-worse needs --wait");
10668
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");
10669
11245
  const pollSeconds = Number(f["poll-seconds"] ?? 20);
10670
11246
  if (!(timeoutMinutes > 0))
10671
11247
  problems.push("--timeout-minutes must be > 0");
@@ -10685,6 +11261,7 @@ function planFarmRun(f) {
10685
11261
  driverArgs: f["driver-arg"] ?? [],
10686
11262
  out: resolve6(f.out ?? "farm-run"),
10687
11263
  timeoutMinutes,
11264
+ killAfterSeconds,
10688
11265
  pollSeconds
10689
11266
  };
10690
11267
  }
@@ -10708,8 +11285,8 @@ function printPlan(p, f, rt) {
10708
11285
  const line = (l) => rt.print(redact(l, secrets));
10709
11286
  line("keepsnappy farm run: dry run, no request is made and nothing is written");
10710
11287
  line(` 1. validate: ${p.journeys.join(", ")} x ${p.repeat} on ${p.device} (${p.platform}), driver ${p.driver}`);
10711
- 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)`}`);
10712
- 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})`);
10713
11290
  line(` 4. tunnel: ${p.ports.length ? `BrowserStackLocal for bs-local.com ports ${p.ports.join(", ")}, identifier = run id` : "none (no --tunnel-for)"}`);
10714
11291
  if (p.driver === "maestro") {
10715
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`);
@@ -10718,7 +11295,7 @@ function printPlan(p, f, rt) {
10718
11295
  }
10719
11296
  for (const [k, v] of p.env)
10720
11297
  line(` env ${k}=${substitute(v, {})}`);
10721
- 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`)}`);
10722
11299
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
10723
11300
  line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse");
10724
11301
  }
@@ -10742,13 +11319,13 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10742
11319
  timeoutMinutes: p.timeoutMinutes,
10743
11320
  pollSeconds: p.pollSeconds,
10744
11321
  onStarted: async (buildId) => {
10745
- 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)}
10746
11323
  `);
10747
11324
  rt.print(`build ${buildId} started: https://app-automate.browserstack.com/dashboard/v2/builds/${buildId}`);
10748
11325
  },
10749
11326
  onStatus: (buildId, status) => rt.print(`build ${buildId}: ${status}`)
10750
11327
  });
10751
- 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))}
10752
11329
  `);
10753
11330
  return detail.status === "passed" ? { ok: true, message: null } : { ok: false, message: `the device build ${detail.buildId} ended ${detail.status}` };
10754
11331
  }
@@ -10785,6 +11362,7 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10785
11362
  const r = await deps.spawner.run(f["driver-script"] ? "node" : driverRuntime(entry), args, {
10786
11363
  env,
10787
11364
  timeoutMs: p.timeoutMinutes * 60000,
11365
+ killAfterMs: p.killAfterSeconds * 1000,
10788
11366
  onOutput: (chunk) => {
10789
11367
  streamed = true;
10790
11368
  const lines = (pending + chunk).split(`
@@ -10799,11 +11377,11 @@ async function runDriver2(deps, rt, f, p, ports, ctx) {
10799
11377
  for (const l of `${r.stdout}${r.stderr}`.split(`
10800
11378
  `))
10801
11379
  emit(l);
10802
- 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"}` };
10803
11381
  }
10804
11382
  async function farmRun(deps, f, rt, ports) {
10805
11383
  const p = planFarmRun(f);
10806
- const statePath = join15(p.out, "keepsnappy-farm.json");
11384
+ const statePath = join16(p.out, "keepsnappy-farm.json");
10807
11385
  if (await deps.fs.exists(statePath))
10808
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)`]);
10809
11387
  if (f["dry-run"]) {
@@ -10811,7 +11389,7 @@ async function farmRun(deps, f, rt, ports) {
10811
11389
  return EXIT.ok;
10812
11390
  }
10813
11391
  const farm = ports ?? nodeFarmRunPorts(deps, rt);
10814
- const tokensJs = join15(p.out, "keepsnappy-tokens.js");
11392
+ const tokensJs = join16(p.out, "keepsnappy-tokens.js");
10815
11393
  await deps.fs.mkdir(p.out);
10816
11394
  const resolved = await farm.apps.resolve(p.app, { outDir: p.out });
10817
11395
  const commit = f.commit ?? resolved.commit ?? undefined;
@@ -10842,9 +11420,9 @@ async function farmRun(deps, f, rt, ports) {
10842
11420
  const runId = f["run-id"] ?? `keepsnappy-${p.platform}-${state.run_set_id.slice(0, 8)}`;
10843
11421
  const ctx = { runId, out: p.out, tokensJs, runSetId: state.run_set_id, app: resolved.app };
10844
11422
  try {
10845
- 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`)]);
10846
11424
  if (p.ports.length) {
10847
- 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") });
10848
11426
  rt.print(`tunnel ${runId}: bs-local.com ports ${p.ports.join(", ")}`);
10849
11427
  }
10850
11428
  driver = await runDriver2(deps, rt, f, p, farm, { ...ctx, tunnelId: tunnel?.identifier ?? null });
@@ -10855,7 +11433,7 @@ async function farmRun(deps, f, rt, ports) {
10855
11433
  rt.print(`device run failed: ${driver.message}; closing the run set with what arrived`);
10856
11434
  closeCode = await farmClose(deps, {
10857
11435
  state: statePath,
10858
- "bs-build": join15(p.out, `browserstack.${p.platform}.json`),
11436
+ "bs-build": join16(p.out, `browserstack.${p.platform}.json`),
10859
11437
  out: p.out,
10860
11438
  ...f.wait ? { wait: true } : {},
10861
11439
  ...f["fail-on-worse"] ? { "fail-on-worse": true } : {},
@@ -10876,13 +11454,82 @@ async function farmRun(deps, f, rt, ports) {
10876
11454
  }
10877
11455
 
10878
11456
  // apps/agent/src/sourcemaps.ts
10879
- import { createHash as createHash2 } from "node:crypto";
10880
- import { readFile as readFile4 } from "node:fs/promises";
10881
- 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";
10882
11460
  import { parseArgs as parseArgs2 } from "node:util";
10883
11461
  import { gzipSync } from "node:zlib";
10884
11462
  var MAX_COMPLETE_ROUNDS = 20;
10885
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
+ }
10886
11533
  function stripSourcesContent(map) {
10887
11534
  if (!isObject(map))
10888
11535
  throw new ConfigError(["not a source map: the file is not a JSON object"]);
@@ -10958,21 +11605,35 @@ async function uploadSourcemaps(opts) {
10958
11605
  }
10959
11606
  return out;
10960
11607
  }
10961
- 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)`;
10962
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) {
10963
11621
  let parsed;
10964
11622
  try {
10965
11623
  parsed = parseArgs2({
10966
11624
  args: [...args],
10967
11625
  allowPositionals: true,
10968
11626
  options: {
11627
+ eas: { type: "boolean" },
10969
11628
  build: { type: "string" },
10970
11629
  platform: { type: "string" },
10971
11630
  kind: { type: "string" },
10972
11631
  flavour: { type: "string" },
10973
11632
  api: { type: "string" },
10974
11633
  "ingest-url": { type: "string" },
10975
- "key-env": { type: "string" }
11634
+ "key-env": { type: "string" },
11635
+ "project-root": { type: "string" },
11636
+ "components-config": { type: "string" }
10976
11637
  }
10977
11638
  });
10978
11639
  } catch (e) {
@@ -10982,6 +11643,40 @@ async function sourcemapsCommand(args, rt) {
10982
11643
  const [sub, ...files] = positionals;
10983
11644
  if (sub !== "upload")
10984
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
+ }
10985
11680
  const problems = [];
10986
11681
  if (!f.build)
10987
11682
  problems.push("--build <full commit sha (git rev-parse HEAD) or build id> is required");
@@ -10991,13 +11686,10 @@ async function sourcemapsCommand(args, rt) {
10991
11686
  problems.push("--kind must be hermes or packager");
10992
11687
  if (files.length === 0)
10993
11688
  problems.push("give at least one source map file");
10994
- const keyEnv = f["key-env"] ?? "KEEPSNAPPY_KEY";
10995
- const key = projectKey(rt.env, keyEnv);
10996
11689
  if (!key)
10997
11690
  problems.push(`the project key (run scope) is missing: set the environment variable ${keyEnv}`);
10998
11691
  if (problems.length)
10999
11692
  throw new ConfigError(problems);
11000
- const url = f.api ?? f["ingest-url"] ?? envValue(rt.env, "API") ?? DEFAULT_API_URL;
11001
11693
  const done = await uploadSourcemaps({
11002
11694
  url,
11003
11695
  key,
@@ -11007,11 +11699,18 @@ async function sourcemapsCommand(args, rt) {
11007
11699
  ...f.kind ? { kind: f.kind } : {},
11008
11700
  ...f.flavour ? { flavour: f.flavour } : {},
11009
11701
  ...rt.fetch ? { fetch: rt.fetch } : {},
11010
- ...rt.readFile ? { readFile: rt.readFile } : {}
11702
+ ...preparedFiles ? { readFile: async (path) => preparedFiles.get(path) } : rt.readFile ? { readFile: rt.readFile } : {}
11011
11703
  });
11012
11704
  for (const d of done) {
11013
11705
  rt.print(`${d.file}: uploaded to build ${d.buildId} (${d.gzBytes} bytes gzip, sourcesContent removed from ${d.strippedSources} sources)`);
11014
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
+ }
11015
11714
  return 0;
11016
11715
  }
11017
11716
 
@@ -11019,36 +11718,94 @@ async function sourcemapsCommand(args, rt) {
11019
11718
  import { parseArgs as parseArgs3 } from "node:util";
11020
11719
 
11021
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
+ }
11022
11771
  var LABEL_W = 14;
11023
11772
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
11024
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.";
11025
11775
  function formatFinding(f, opts = {}) {
11026
- const out = [
11027
- f.answer.headline,
11028
- line("What changed", f.answer.what_changed),
11029
- line("Where", f.answer.where),
11030
- line("How sure", f.answer.how_sure),
11031
- line("Why", f.answer.why),
11032
- line("What to do", f.answer.what_to_do),
11033
- line("Seen", f.answer.lifecycle),
11034
- line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
11035
- line("Id", f.id)
11036
- ];
11037
- if (f.why.components.length > 1) {
11038
- out.push(line("Components", ""));
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));
11782
+ if (f.why.components.length) {
11783
+ r.full(line("Components", ""));
11039
11784
  for (const c of f.why.components) {
11040
- 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})` : ""}`);
11041
11794
  }
11042
11795
  }
11043
11796
  if (opts.prompt)
11044
- out.push("", f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
11045
- return out.join(`
11046
- `);
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
+ ]);
11047
11804
  }
11048
11805
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
11049
11806
  var LANES_SHOWN = 8;
11050
11807
  var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
11051
- function measuredLine(l) {
11808
+ function measuredLines(l, r) {
11052
11809
  const f = l.filters;
11053
11810
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
11054
11811
  const m = l.measured;
@@ -11056,56 +11813,107 @@ function measuredLine(l) {
11056
11813
  if (!n) {
11057
11814
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
11058
11815
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
11059
- 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 [];
11060
11818
  }
11061
11819
  const lane = (x) => {
11062
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}` : "";
11063
- 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)}` : ""}`;
11064
11822
  };
11065
- if (m.lanes.length === 1)
11066
- 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
+ }
11067
11827
  const shown = m.lanes.slice(0, LANES_SHOWN);
11068
- return [
11069
- `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`,
11070
- ...shown.map((x) => ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`),
11071
- ...m.lanes.length > shown.length ? [` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`] : []
11072
- ].join(`
11073
- `);
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"] : []];
11074
11835
  }
11075
11836
  function countsLine2(l) {
11076
11837
  const c = l.counts;
11077
11838
  const parts = [
11078
- c.worse ? `${c.worse} worse${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
11079
- c.warn ? plural(c.warn, "warning") : "",
11080
- c.better ? `${c.better} better` : ""
11839
+ c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
11840
+ c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
11841
+ c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
11081
11842
  ].filter(Boolean);
11082
11843
  const state = l.filters.state;
11083
11844
  const unjudged = l.measured.lanes.filter((x) => !x.judged);
11084
- const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : "Nothing worse, no warning, nothing better";
11845
+ const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
11085
11846
  const heated = c.heat_runs ?? 0;
11086
11847
  const more = [
11087
- heated ? `${plural(heated, "run")} heated up the phone (run health, layer 3)` : "",
11848
+ heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
11088
11849
  state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
11089
11850
  ].filter(Boolean);
11090
11851
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
11091
11852
  return line.charAt(0).toUpperCase() + line.slice(1);
11092
11853
  }
11093
- function formatFindingList(l) {
11094
- const out = [measuredLine(l)];
11854
+ function formatFindingList(l, opts = {}) {
11855
+ const r = new Rows;
11856
+ const lanesLeft = measuredLines(l, r);
11095
11857
  if (l.measured.newest || l.total)
11096
- out.push(countsLine2(l));
11858
+ r.both(countsLine2(l));
11097
11859
  l.findings.forEach((f, i) => {
11098
- 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}`);
11099
11864
  });
11100
11865
  const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
11101
11866
  if (l.next_offset !== null)
11102
- 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}).`);
11103
11868
  else if (l.offset > 0)
11104
- 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}.`);
11105
11870
  if (l.findings.length)
11106
- out.push("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
11107
- return out.join(`
11108
- `);
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"] : []]);
11109
11917
  }
11110
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";
11111
11919
  function formatCompare(r) {
@@ -11127,19 +11935,20 @@ function formatCompare(r) {
11127
11935
  return out.join(`
11128
11936
  `);
11129
11937
  }
11130
- function formatHistory(h) {
11131
- 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"}`);
11132
11941
  const ms = h.measures.slice(0, 4);
11133
11942
  if (h.points.length) {
11134
- out.push(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
11135
- for (const p of h.points) {
11136
- 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)}`);
11137
- }
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
+ });
11138
11947
  }
11139
11948
  if (h.findings.length)
11140
- out.push("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
11141
- return out.join(`
11142
- `);
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")] : []);
11143
11952
  }
11144
11953
  function formatCommits(c) {
11145
11954
  return [
@@ -11149,6 +11958,55 @@ function formatCommits(c) {
11149
11958
  ].join(`
11150
11959
  `);
11151
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
+ }
11152
12010
  function formatLayers(l) {
11153
12011
  return [
11154
12012
  l.note,
@@ -11158,6 +12016,100 @@ function formatLayers(l) {
11158
12016
  ].join(`
11159
12017
  `);
11160
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
+ }
11161
12113
 
11162
12114
  // packages/mcp/dist/src/client.js
11163
12115
  class ApiError extends Error {
@@ -11172,9 +12124,13 @@ class ApiError extends Error {
11172
12124
  }
11173
12125
  async function get(t, path, query = {}) {
11174
12126
  const u = new URL(path, t.url.endsWith("/") ? t.url : `${t.url}/`);
11175
- for (const [k, v] of Object.entries(query))
11176
- 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 !== "")
11177
12132
  u.searchParams.set(k, String(v));
12133
+ }
11178
12134
  const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
11179
12135
  const text = await r.text();
11180
12136
  const body = text ? JSON.parse(text) : {};
@@ -11187,19 +12143,47 @@ async function get(t, path, query = {}) {
11187
12143
  return body;
11188
12144
  }
11189
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 });
11190
12147
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
11191
12148
  var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
11192
12149
  var windowHistory = (t, q) => get(t, "v1/history", q);
11193
12150
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
11194
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 });
11195
12172
 
11196
12173
  // apps/agent/src/findings.ts
11197
- var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--json]
11198
- finding <id> [--prompt] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
11199
- compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
11200
- history <journey> <window> [--phone MODEL] [--json] (window id or its name, e.g. "Tap · “live chat send”")
11201
- 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]
11202
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)
11203
12187
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
11204
12188
 
11205
12189
  class UsageError extends ConfigError {
@@ -11211,7 +12195,8 @@ class UsageError extends ConfigError {
11211
12195
  }
11212
12196
  }
11213
12197
  var STATES = ["present", "not-judged", "resolved", "all"];
11214
- 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"]);
11215
12200
  async function findingsCommand(cmd, argv, rt) {
11216
12201
  const args = (() => {
11217
12202
  try {
@@ -11224,12 +12209,22 @@ async function findingsCommand(cmd, argv, rt) {
11224
12209
  journey: { type: "string" },
11225
12210
  phone: { type: "string" },
11226
12211
  state: { type: "string" },
12212
+ session: { type: "string" },
12213
+ "vs-previous": { type: "boolean" },
11227
12214
  limit: { type: "string" },
11228
12215
  offset: { type: "string" },
12216
+ "run-set": { type: "string" },
12217
+ measure: { type: "string" },
12218
+ sort: { type: "string" },
12219
+ "top-components": { type: "string" },
11229
12220
  json: { type: "boolean" },
12221
+ short: { type: "boolean" },
11230
12222
  prompt: { type: "boolean" },
11231
12223
  api: { type: "string" },
11232
- "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" }
11233
12228
  }
11234
12229
  });
11235
12230
  } catch (e) {
@@ -11242,8 +12237,9 @@ async function findingsCommand(cmd, argv, rt) {
11242
12237
  if (!key)
11243
12238
  throw new ConfigError([`a project key with read scope is needed: set the environment variable ${keyEnv}`]);
11244
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" };
11245
12241
  const out = (v, text) => {
11246
- rt.print(o.json ? JSON.stringify(v, null, 2) : text(v));
12242
+ rt.print(o.json ? JSON.stringify(v, null, 2) : text(v, answerForm));
11247
12243
  return 0;
11248
12244
  };
11249
12245
  const need = (n, what) => {
@@ -11253,10 +12249,29 @@ async function findingsCommand(cmd, argv, rt) {
11253
12249
  const limit = o.limit === undefined ? undefined : Number(o.limit);
11254
12250
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200))
11255
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`]);
11256
12254
  const offset = o.offset === undefined ? undefined : Number(o.offset);
11257
12255
  if (offset !== undefined && (!Number.isInteger(offset) || offset < 0))
11258
12256
  throw new UsageError(["--offset must be a whole number >= 0"]);
11259
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
+ }
11260
12275
  case "findings": {
11261
12276
  if (o.branch && o.main)
11262
12277
  throw new UsageError(["give --branch or --main, not both"]);
@@ -11273,14 +12288,18 @@ async function findingsCommand(cmd, argv, rt) {
11273
12288
  ...offset ? { offset } : {}
11274
12289
  }), formatFindingList);
11275
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);
11276
12295
  case "finding": {
11277
12296
  need(1, "<id>");
11278
12297
  const f = await getFinding(t, p[0]);
11279
12298
  if (o.prompt && !o.json) {
11280
- rt.print(f.what_to_do.prompt ?? "No prompt: this finding is an improvement.");
12299
+ rt.print(promptText(f));
11281
12300
  return 0;
11282
12301
  }
11283
- return out(f, (x) => formatFinding(x));
12302
+ return out(f, formatFinding);
11284
12303
  }
11285
12304
  case "compare":
11286
12305
  need(2, "<runA> <runB>");
@@ -11291,8 +12310,39 @@ async function findingsCommand(cmd, argv, rt) {
11291
12310
  case "commits":
11292
12311
  need(1, "<from>");
11293
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
+ }
11294
12333
  case "layers":
11295
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
+ }
11296
12346
  default:
11297
12347
  throw new UsageError([FINDINGS_USAGE]);
11298
12348
  }
@@ -11308,17 +12358,19 @@ var USAGE = `keepsnappy <command>
11308
12358
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
11309
12359
  [--clock-offset-ms N] [--no-markers]
11310
12360
  -- <test command...>
11311
- 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)
11312
12363
  --tokens-js <file> --state <file> [--branch NAME --commit SHA --base-commit SHA] [--ttl-seconds 21600] [--key-env KEEPSNAPPY_KEY]
11313
12364
  [--config keepsnappy.yml] [--flows <files|dirs|globs,...>] (journey titles: keepsnappy.yml flows.paths, else the flow's name: / top comment)
11314
12365
  [--completion-marker] (the flow posts step run:complete at the end of each iteration; runs without it are invalid)
11315
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)
11316
12367
  [--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
11317
12368
  farm close --state <file> [--wait [--fail-on-worse]] [--bs-build browserstack.ios.json] [--settle-seconds 60] [--max-wait-seconds 300] [--out DIR]
11318
- 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]
11319
12370
  [--tunnel-for localhost:PORT ...] [--env KEY=VALUE ...] (values may use {tunnel:PORT} {run_id} {out} {tokens_js} {run_set_id})
11320
12371
  maestro: --suite-dir DIR --execute FLOW [--tokens-path flows/keepsnappy/tokens.js] [--maestro-version V]
11321
- 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)
11322
12374
  [--branch NAME --commit SHA] [--wait [--fail-on-worse]] [--out DIR] [--dry-run] (open + tunnel + device + close; docs/plans/54a)
11323
12375
  extract <out-dir> --ri-capture observations.ndjson [--mem-window NAME]
11324
12376
  aa <out-dir> [--reps N] [--boot N]
@@ -11416,6 +12468,7 @@ async function main2(argv) {
11416
12468
  "tokens-path": { type: "string" },
11417
12469
  "maestro-version": { type: "string" },
11418
12470
  "timeout-minutes": { type: "string" },
12471
+ "kill-after-seconds": { type: "string" },
11419
12472
  "poll-seconds": { type: "string" },
11420
12473
  "custom-id": { type: "string" },
11421
12474
  "run-id": { type: "string" },
@@ -11449,9 +12502,9 @@ async function main2(argv) {
11449
12502
  if (platform === "ios-device" && !o.device)
11450
12503
  throw new Error("--device <udid> is required for ios-device");
11451
12504
  const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
11452
- const out = resolve7(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
12505
+ const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
11453
12506
  const udid = o.device ?? "booted";
11454
- 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"));
11455
12508
  const installer = platform === "ios-sim" ? new IosSimulatorInstaller(deps, udid) : new IosDeviceInstaller(deps, udid);
11456
12509
  const bundleId = await bundleIdOf(deps, arms[0]?.target, o["bundle-id"]);
11457
12510
  let markers;
@@ -11470,7 +12523,7 @@ async function main2(argv) {
11470
12523
  outDir: out,
11471
12524
  testCommand: positionals,
11472
12525
  bundleId,
11473
- ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve7(o["ri-capture"])) } : {},
12526
+ ...o["ri-capture"] ? { ri: new FileRiSource(deps, resolve8(o["ri-capture"])) } : {},
11474
12527
  ...o["mem-window"] ? { memWindow: o["mem-window"] } : {},
11475
12528
  ...o.timeout ? { testTimeoutMs: Number(o.timeout) * 1000 } : {}
11476
12529
  });
@@ -11482,9 +12535,9 @@ async function main2(argv) {
11482
12535
  const dir = positionals[0];
11483
12536
  if (!dir)
11484
12537
  throw new Error("extract needs <out-dir>");
11485
- const ri = o["ri-capture"] ? new FileRiSource(deps, resolve7(o["ri-capture"])) : undefined;
11486
- const rows = await extract(deps, resolve7(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
11487
- 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")}`);
11488
12541
  return 0;
11489
12542
  }
11490
12543
  if (cmd === "aa") {
@@ -11492,7 +12545,7 @@ async function main2(argv) {
11492
12545
  if (!dir)
11493
12546
  throw new Error("aa needs <out-dir>");
11494
12547
  const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
11495
- console.log((await aa(deps, resolve7(dir), { config })).table);
12548
+ console.log((await aa(deps, resolve8(dir), { config })).table);
11496
12549
  return 0;
11497
12550
  }
11498
12551
  console.error(USAGE);