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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -212,6 +212,15 @@ class ConfigError extends Error {
212
212
  this.name = "ConfigError";
213
213
  }
214
214
  }
215
+
216
+ class UsageError extends ConfigError {
217
+ constructor(lines) {
218
+ super(lines);
219
+ this.message = lines.join(`
220
+ `);
221
+ this.name = "UsageError";
222
+ }
223
+ }
215
224
  var TITLE_MAX = 200;
216
225
  var DESCRIPTION_MAX = 2000;
217
226
  var TOP = ["project", "app", "platform", "device", "repetitions", "flows", "steps", "output", "timeout_seconds"];
@@ -5171,7 +5180,7 @@ async function call(f, base, path, headers, body, method = "POST") {
5171
5180
  try {
5172
5181
  return text ? JSON.parse(text) : {};
5173
5182
  } catch {
5174
- return {};
5183
+ throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
5175
5184
  }
5176
5185
  }
5177
5186
  async function uploadRuns(deps, outDir, runs, opts) {
@@ -5276,6 +5285,7 @@ var STATE_WORDS = {
5276
5285
  var INFO_WORDS = { heat: "Phone heated up" };
5277
5286
  var isWorseState = (state) => state === "worse";
5278
5287
  var NOT_BLOCKING = "not blocking";
5288
+ var badgedWord = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? `${word} (${NOT_BLOCKING})` : word;
5279
5289
 
5280
5290
  // packages/present/dist/src/values.js
5281
5291
  var NO_VALUE = "–";
@@ -5348,6 +5358,39 @@ var JOURNEY_ROW_WORDS = {
5348
5358
  notInRun: upperFirst(GONE_TAILS.journey.notInRun),
5349
5359
  removed: upperFirst(GONE_TAILS.journey.removed)
5350
5360
  };
5361
+ var NEW_IN_PR_CHIP = "New in this PR";
5362
+ var placeNamesChangedTitle = (sdk) => `Place names changed with SDK ${sdk}`;
5363
+ var restartRowText = (sdk, mainSessions, needed) => `${placeNamesChangedTitle(sdk)}: ${restartContextText(mainSessions, needed)}`;
5364
+ var restartContextText = (mainSessions, needed) => `main has ${mainSessions} of ${needed} sessions on the new names`;
5365
+ function removedRowsText(n, page) {
5366
+ return { lead: `${plural(n, "component", "components")} ${GONE_TAILS[page].removed}:`, rest: " not counted." };
5367
+ }
5368
+ function notInRunRowsText(c, page) {
5369
+ const what = [c.screens ? plural(c.screens, "screen", "screens") : "", c.places ? `${c.places} ${c.places === 1 ? "tap or scroll" : "taps and scrolls"}` : ""].filter(Boolean).join(" and ");
5370
+ const them = c.screens + c.places === 1 ? "it" : "them";
5371
+ return { lead: `${what} ${GONE_TAILS[page].notInRun}:`, rest: ` the ${page === "journey" ? "newest session" : "journey"} did not reach ${them}; not counted.` };
5372
+ }
5373
+ var OUTSIDE_APP_NAME = "Outside app components";
5374
+ var OTHER_RENDER_WORK_NAME = "Other render work";
5375
+ var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
5376
+ var rolledUpText = (rolledUpMs, totalMs) => `From libraries: ${valueText("self_ms", rolledUpMs)} of ${valueText("self_ms", totalMs)}`;
5377
+ var libraryLineText = (l) => `${l.name} · ${l.renders} rendered · ${valueText("self_ms", l.ms)}`;
5378
+ var rolledUpRestText = (rest) => `${plural(rest.n, "other library", "other libraries")} · ${valueText("self_ms", rest.ms)}`;
5379
+ var measureWords = (measure) => isMeasureId(measure) ? MEASURES[measure].label : `component ${BREAKDOWN_MEASURES[measure].label}`;
5380
+ var TIER_STATE = { worse: "worse", slight: "warning", better: "better", same: "no-change" };
5381
+ function leadBody(subject, measure, main, pr) {
5382
+ const unit = shownUnit(isMeasureId(measure) ? MEASURES[measure].unit : BREAKDOWN_MEASURES[measure].unit).unit;
5383
+ const to = valueText(measure, pr);
5384
+ const from = valueText(measure, main);
5385
+ const bare = unit && from.endsWith(` ${unit}`) && to.endsWith(` ${unit}`) ? from.slice(0, from.length - unit.length - 1) : from;
5386
+ return `${subject}, ${measureWords(measure)} ${bare} → ${to}`;
5387
+ }
5388
+ var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5389
+ var FOR_INFORMATION = "For information";
5390
+ var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5391
+ function foldedText(n, rows, page) {
5392
+ return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5393
+ }
5351
5394
  function placeShownName(p, o = {}) {
5352
5395
  const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5353
5396
  const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
@@ -5355,7 +5398,7 @@ function placeShownName(p, o = {}) {
5355
5398
  }
5356
5399
  var libraryViewName = (kind, hostType) => `${kind === "drag" ? "Scroll" : "Tap"} outside app components (${hostType})`;
5357
5400
  function componentShownName(name, file) {
5358
- if (name !== "default")
5401
+ if (name !== "default" || !file)
5359
5402
  return name;
5360
5403
  const path = file.replace(/\.[^.\/]+$/, "");
5361
5404
  const route = /^(?:src\/)?app\/(.*)$/.exec(path)?.[1];
@@ -5394,7 +5437,20 @@ var BACKGROUND_SOURCES = {
5394
5437
  touch: "work of a tap outside its window",
5395
5438
  input: "work of typing outside its window"
5396
5439
  };
5440
+ var OPEN_WORDS = { first: "first time", repeat: "again" };
5397
5441
  var OPEN_MARK = /#(1|repeat)$/;
5442
+ var isInputKind = (kind) => kind === "press" || kind === "drag";
5443
+ function placeOpen(id) {
5444
+ const ref = placeRef(id);
5445
+ if (!isInputKind(ref.kind))
5446
+ return null;
5447
+ const m = OPEN_MARK.exec(ref.stem);
5448
+ return m ? m[1] === "repeat" ? "repeat" : "first" : null;
5449
+ }
5450
+ var placeOpenWord = (id) => {
5451
+ const open = placeOpen(id);
5452
+ return open ? OPEN_WORDS[open] : null;
5453
+ };
5398
5454
  function placeIdShownName(id, o = {}) {
5399
5455
  const p = placeParts(id);
5400
5456
  if (!p)
@@ -5438,6 +5494,14 @@ function placeKindWord(id) {
5438
5494
  const { kind } = placeRef(id);
5439
5495
  return kind === "other" ? "Window" : KIND_WORD[kind];
5440
5496
  }
5497
+ function windowQualified(id) {
5498
+ const word = placeOpenWord(id);
5499
+ const label = placeLabel(id);
5500
+ if (!word)
5501
+ return label;
5502
+ const after = placeRef(id).segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5503
+ return `${after ? label.slice(0, -after.length) : label} (${word})${after}`;
5504
+ }
5441
5505
  var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
5442
5506
  var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
5443
5507
  function placeLabel(id, screen) {
@@ -5456,27 +5520,33 @@ function placeLabel(id, screen) {
5456
5520
  const colon = name.indexOf(": ");
5457
5521
  return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
5458
5522
  }
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`;
5523
+ // packages/present/dist/src/placeNames.js
5524
+ function shortestPaths(files) {
5525
+ const out = new Map;
5526
+ for (const file of files) {
5527
+ const parts = file.split("/");
5528
+ let shown = file;
5529
+ for (let k = 1;k <= parts.length; k++) {
5530
+ const tail = parts.slice(-k).join("/");
5531
+ if (!files.some((f) => f !== file && f.split("/").slice(-k).join("/") === tail)) {
5532
+ shown = tail;
5533
+ break;
5534
+ }
5535
+ }
5536
+ out.set(file, shown);
5537
+ }
5538
+ return out;
5539
+ }
5472
5540
  // packages/present/dist/src/stability.js
5473
5541
  var UNSTABLE_WORDS = {
5474
5542
  short: "main varies too much here",
5475
5543
  label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
5476
5544
  line: "Main varies too much here to tell",
5477
5545
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5546
+ note: "This place varies a lot between runs. Worth checking why: it may be in the code.",
5478
5547
  what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
5479
5548
  };
5549
+
5480
5550
  // packages/present/dist/src/present.js
5481
5551
  var ASPECT_WORDS = {
5482
5552
  renderer: "React renderer",
@@ -5493,6 +5563,127 @@ var ASPECT_WORDS = {
5493
5563
  endGc: "memory clean-up at the journey end",
5494
5564
  cpuProfiling: "CPU profiling"
5495
5565
  };
5566
+ var measuredDifferentlyText = (aspects) => `measured differently (${aspects.map((a) => ASPECT_WORDS[a] ?? a).join(", ")})`;
5567
+
5568
+ // packages/present/dist/src/readWords.js
5569
+ function componentReadName(c) {
5570
+ const ref = (c.key ? parseComponentKey(c.key) : null) ?? parseComponentKey(c.name);
5571
+ return componentShownName(ref?.name ?? c.name, c.file ?? ref?.file ?? "");
5572
+ }
5573
+ function componentReadNames(cs) {
5574
+ const names = cs.map(componentReadName);
5575
+ const rawNames = cs.map((c) => (c.key ? parseComponentKey(c.key)?.name : undefined) ?? parseComponentKey(c.name)?.name ?? c.name);
5576
+ return cs.map((c, i) => {
5577
+ const equals = cs.filter((_, k) => rawNames[k] === rawNames[i]);
5578
+ const file = c.file ?? (c.key ? parseComponentKey(c.key)?.file : undefined);
5579
+ const files = equals.flatMap((x) => x.file ?? (x.key ? parseComponentKey(x.key)?.file ?? [] : []));
5580
+ return new Set(files).size > 1 && file ? `${names[i]} · ${shortestPaths(files).get(file)}` : names[i];
5581
+ });
5582
+ }
5583
+ function storedRefWords(ref, peers = []) {
5584
+ if (!ref.startsWith("b:component:"))
5585
+ return ref;
5586
+ const cut = ref.lastIndexOf(":");
5587
+ const key = ref.slice("b:component:".length, cut);
5588
+ const components = [ref, ...peers].filter((r) => r.startsWith("b:component:")).map((r) => {
5589
+ const componentKey = r.slice("b:component:".length, r.lastIndexOf(":"));
5590
+ return { key: componentKey, name: componentKey };
5591
+ });
5592
+ const name = componentReadNames(components)[0] ?? componentReadName({ key, name: key });
5593
+ return `${name}, ${measureWords(ref.slice(cut + 1))}`;
5594
+ }
5595
+ function resultContextLines(c, verbose = false, short = false) {
5596
+ if (!c)
5597
+ return [];
5598
+ const out = [];
5599
+ if (c.namesChanged)
5600
+ out.push(restartRowText(c.namesChanged.sdk, c.namesChanged.mainSessions, c.namesChanged.needed));
5601
+ for (const w of c.windows) {
5602
+ const shownPlace = `${windowQualified(w.place)}${verbose ? ` (${w.place})` : ""}`;
5603
+ if (w.slowestCommit)
5604
+ out.push(`${shownPlace}: ${MEASURES.commit_max_ms.label}: ${valueText("commit_max_ms", w.slowestCommit.main)} → ${valueText("commit_max_ms", w.slowestCommit.pr)}`);
5605
+ if (!w.cause)
5606
+ continue;
5607
+ const s = w.cause.subject;
5608
+ const subject = s.kind === "component" ? `${componentShownName(s.ref.name, s.ref.file)}${verbose ? ` (${s.ref.file})` : ""}` : placeShownName({ kind: placeParts(s.place)?.kind === "drag" ? "drag" : "press", target: s.target, parent: s.parent, k: s.k, ...s.host ? { host: s.host } : {} });
5609
+ out.push(`${shownPlace}: ${w.information || !w.tier ? informationLeadText(subject, w.cause.measure, w.cause.main, w.cause.pr) : screenLeadText(w.tier, subject, w.cause.measure, w.cause.main, w.cause.pr)}${w.cause.measuredDifferently?.length ? `; ${measuredDifferentlyText(w.cause.measuredDifferently)}` : ""}`);
5610
+ }
5611
+ const screens = new Map;
5612
+ for (const p of c.notInThisRun) {
5613
+ const parts = placeParts(p.place);
5614
+ const key = parts ? componentShownName(parts.screen.name, parts.screen.file ?? "") : "";
5615
+ screens.set(key, [...screens.get(key) ?? [], p]);
5616
+ }
5617
+ for (const [screen, rows] of screens) {
5618
+ const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5619
+ out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5620
+ if (rows.length > 3)
5621
+ out.push(` ${foldedText(rows.length, "places", c.page)}`);
5622
+ if (!short || rows.length <= 3)
5623
+ out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5624
+ }
5625
+ const removed = new Map;
5626
+ for (const r of c.removed)
5627
+ removed.set(r.place, [...removed.get(r.place) ?? [], r]);
5628
+ for (const [place, rows] of removed) {
5629
+ const line = removedRowsText(rows.length, c.page);
5630
+ out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5631
+ if (rows.length > 3)
5632
+ out.push(` ${foldedText(rows.length, "components", c.page)}`);
5633
+ if (!short || rows.length <= 3)
5634
+ out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5635
+ }
5636
+ return out;
5637
+ }
5638
+ function laneContextLines(context, journey, phone, verbose = false, short = false) {
5639
+ const lines = resultContextLines(context, verbose, short);
5640
+ return lines.length ? [`${journey} on ${phone}:`, ...lines.map((line) => ` ${line}`)] : [];
5641
+ }
5642
+ var exactComponentMs = (ms) => `${ms} ms`;
5643
+ var componentPageText = (page, count) => `Component rows: ${count ? `${page.offset + 1}–${page.offset + count}` : "0"} of ${page.total} stored${page.next_offset !== null ? `; next --page ${Math.floor(page.next_offset / page.limit) + 1} (MCP component_offset ${page.next_offset})` : ""}.`;
5644
+ var omissionText = (o) => `${o.run_id}: SDK omitted ${o.sdkOmitted ?? "unknown"}; Derivation left out ${o.omitted ?? "unknown"}${o.selfMs === null ? "" : ` · ${exactComponentMs(o.selfMs)}`}. Paging leaves recorded omissions unchanged.`;
5645
+ var READ_WORDS = {
5646
+ componentTime: "component render time",
5647
+ ownTime: "own time",
5648
+ detailUnavailable: "Only render time recorded; renders, parent and library detail unavailable.",
5649
+ parent: "Placed by",
5650
+ slowest: MEASURES.commit_max_ms.label,
5651
+ runA: "Run A",
5652
+ runB: "Run B",
5653
+ notMeasured: "Not measured",
5654
+ onlyA: "Only in run A",
5655
+ onlyB: "Only in run B"
5656
+ };
5657
+ var READ_USAGE = {
5658
+ listFindings: `What got ${STATE_WORDS.worse.toLowerCase()} (${STATE_WORDS.worse} / ${STATE_WORDS.warning}) or ${STATE_WORDS.better.toLowerCase()} in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset.`,
5659
+ components: " --page N | --all (places or compare: a component page, or every stored component and place; --top-components sets the bounded page size)",
5660
+ verbose: " --verbose (include source files and raw identities in text; JSON is unchanged)",
5661
+ page: "--page must be a whole number from 1; use it only with places or compare.",
5662
+ all: "--all is for places or compare; do not combine it with --page, --offset or --short.",
5663
+ detailFlag: " --detail (places: include recorded run detail; also included by --verbose or component paging)",
5664
+ detail: "Include recorded per-run component and window detail.",
5665
+ compareTop: "--top-components must be a whole number from 1 to 200 for compare.",
5666
+ top: "--top-components must be a whole number from 0 to 20, or from 1 to 200 with --page or --all.",
5667
+ paging: "The API did not advance the component page; stopped without claiming the list is complete.",
5668
+ componentOffset: "Skip this many stored components per place.",
5669
+ componentLimit: "Component page size; the response gives stored totals and next offsets."
5670
+ };
5671
+ var componentListsText = (totals, shown) => `Stored component rows: ${shown.rows} of ${totals.rows} compared; ${shown.added} of ${totals.added} ${READ_WORDS.onlyB.toLowerCase()}; ${shown.removed} of ${totals.removed} ${READ_WORDS.onlyA.toLowerCase()}.`;
5672
+ var notMeasuredText = (side) => `${READ_WORDS.notMeasured} in run ${side.toUpperCase()}`;
5673
+
5674
+ // packages/present/dist/src/words.js
5675
+ var REASON_WORDS = {
5676
+ "no-baseline": "there are no main runs on this phone and journey yet",
5677
+ "too-few-runs": "there are too few valid runs on a side",
5678
+ "judged-measures-missing": "the judged measures are missing on a side",
5679
+ "measured-differently": "the sides were measured differently",
5680
+ "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5681
+ "facts-version-mismatch": "the sides were derived with different versions",
5682
+ "not-judged-on-simulators": "timing is not judged on simulators",
5683
+ "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5684
+ "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5685
+ };
5686
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5496
5687
  // packages/present/dist/src/main-history.js
5497
5688
  var MAIN_TIER_WORDS = {
5498
5689
  same: "In norm",
@@ -5501,6 +5692,13 @@ var MAIN_TIER_WORDS = {
5501
5692
  slight: STATE_WORDS.warning,
5502
5693
  worse: STATE_WORDS.worse
5503
5694
  };
5695
+ var MAIN_TIER_RESULT_WORDS = {
5696
+ same: STATE_WORDS["no-change"],
5697
+ better: STATE_WORDS.better,
5698
+ cant: STATE_WORDS["cant-tell"],
5699
+ slight: STATE_WORDS.warning,
5700
+ worse: STATE_WORDS.worse
5701
+ };
5504
5702
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5505
5703
  function mainCantWords(reason, config) {
5506
5704
  if (!reason)
@@ -5526,7 +5724,6 @@ function mainPlaceReasonWords(reason, config) {
5526
5724
  }
5527
5725
  }
5528
5726
  var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
5529
- var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
5530
5727
  var SHORT_ASPECT_WORDS = {
5531
5728
  ...ASPECT_WORDS,
5532
5729
  sdk: "SDK",
@@ -5563,6 +5760,7 @@ function mainCutWords(v) {
5563
5760
  }
5564
5761
  return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5565
5762
  }
5763
+ var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5566
5764
  // apps/agent/src/summary.ts
5567
5765
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5568
5766
  function plainMetric(metric) {
@@ -5654,10 +5852,16 @@ var setupLine = (h) => {
5654
5852
  var keyHeader = (t) => ({ "x-keepsnappy-key": t.key });
5655
5853
  async function createRunSet(t, body) {
5656
5854
  const r = await call(t.fetch ?? fetch, t.url, "/v1/run-sets", keyHeader(t), body);
5657
- return { run_set_id: String(r.run_set_id), dashboard_url: String(r.dashboard_url) };
5855
+ if (typeof r.run_set_id !== "string" || !r.run_set_id)
5856
+ throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
5857
+ return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
5658
5858
  }
5659
5859
  async function completeRunSet(t, id, verdicts) {
5660
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/complete`, keyHeader(t), { verdicts });
5860
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
5861
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
5862
+ if (!r.run_set_id)
5863
+ throw new UploadError(`POST ${path}: response has no run_set_id`);
5864
+ return r;
5661
5865
  }
5662
5866
  async function createIngestRun(t, body) {
5663
5867
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
@@ -5672,13 +5876,20 @@ async function finishIngestRun(t, runId, invalidReason, farmSession) {
5672
5876
  });
5673
5877
  }
5674
5878
  async function getRunSetRuns(t, id) {
5675
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/runs`, keyHeader(t), undefined, "GET");
5879
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/runs`;
5880
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET");
5881
+ if (!Array.isArray(r.runs))
5882
+ throw new UploadError(`GET ${path}: response has no runs`);
5883
+ return r;
5676
5884
  }
5677
5885
  var FEW_RUNS = "Few runs — result may be noisy";
5678
- var word = (s, server, h) => {
5679
- const w = server || stateWord(s ?? "inconclusive");
5680
- return h?.blocking === false && h.measured_differently?.length && (w === STATE_WORDS.worse || s === "worse") ? `${w} (${NOT_BLOCKING})` : w;
5681
- };
5886
+ var word = (s, server, blocking) => badgedWord(server || stateWord(s ?? "inconclusive"), blocking);
5887
+ var setWord = (v) => word(v?.state ?? null, v?.word, v?.blocking ?? v?.headline?.blocking);
5888
+ var scenarioWord = (sc) => word(sc.state, sc.word, sc.headline?.blocking);
5889
+ var notComputed = (s) => !s.verdicts || s.verdicts.state === null && !s.verdicts.blocking;
5890
+ var resultWord = (s) => setWord(s.verdicts);
5891
+ var UNCHECKED_GATE = "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing";
5892
+ var uncheckedGate = (s) => notComputed(s) ? UNCHECKED_GATE : null;
5682
5893
  var countsLine = (c) => countsWords(c);
5683
5894
  var noCounts = (c) => !c.worse && !c.warnings && !c.better && !c.noChange && !c.inconclusive;
5684
5895
  var change = (metric, a, b) => {
@@ -5691,7 +5902,7 @@ function formatRunSet(s, top = 5) {
5691
5902
  const lines = [];
5692
5903
  const where = `${s.branch}${s.commit_sha ? ` @ ${s.commit_sha.slice(0, 7)}` : ""}`;
5693
5904
  const v = s.verdicts;
5694
- lines.push(`KeepSnappy result for ${where}: ${word(v?.state ?? null, v?.word, v?.headline)}`);
5905
+ lines.push(`KeepSnappy result for ${where}: ${setWord(v)}`);
5695
5906
  if (setupLine(v?.headline))
5696
5907
  lines.push(` ${setupLine(v?.headline)}`);
5697
5908
  if (v?.low_data)
@@ -5705,13 +5916,14 @@ function formatRunSet(s, top = 5) {
5705
5916
  const dev = sc.device ? `${sc.device.model}${sc.device.os ? ` (${sc.device.os})` : ""}` : "no device";
5706
5917
  const what = sc.role === "comparison" ? "compared with the baseline branch" : sc.role === "history" ? "latest runs compared with earlier ones" : "not judged (no branch or flow name)";
5707
5918
  lines.push("");
5708
- lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${word(sc.state, sc.word, sc.headline)}`);
5919
+ lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${scenarioWord(sc)}`);
5709
5920
  if (setupLine(sc.headline) && (v?.scenarios.length ?? 0) > 1)
5710
5921
  lines.push(` ${setupLine(sc.headline)}`);
5711
5922
  if (sc.low_data && (v?.scenarios.length ?? 0) > 1)
5712
5923
  lines.push(` ${FEW_RUNS}`);
5713
5924
  if (sc.role !== "adhoc" && !noCounts(movedCounts(sc)))
5714
5925
  lines.push(` ${countsLine(movedCounts(sc))}`);
5926
+ lines.push(...resultContextLines(sc.context).map((line) => ` ${line}`));
5715
5927
  if (heatNote(sc))
5716
5928
  lines.push(` ${heatNote(sc)}`);
5717
5929
  const allMoved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn"));
@@ -5743,12 +5955,13 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5743
5955
  }
5744
5956
  const v = s.verdicts;
5745
5957
  const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5958
+ const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", sc.device?.model ?? "-"));
5746
5959
  const rows = v.scenarios.map((sc) => {
5747
5960
  const c = movedCounts(sc);
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})` : "-"} |`;
5961
+ return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${scenarioWord(sc)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5749
5962
  });
5750
5963
  return [
5751
- `### KeepSnappy: ${word(v.state, v.word, v.headline)}`,
5964
+ `### KeepSnappy: ${setWord(v)}`,
5752
5965
  "",
5753
5966
  ...setupLine(v.headline) ? [`${setupLine(v.headline)}.`, ""] : [],
5754
5967
  `\`${s.branch}\`${s.commit_sha ? ` @ \`${s.commit_sha.slice(0, 7)}\`` : ""}, ${s.runs.valid}/${s.runs.total} runs used.${v.low_data ? ` ${FEW_RUNS}.` : ""}`,
@@ -5756,6 +5969,7 @@ Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [
5756
5969
  `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5757
5970
  "|---|---|---|---|---|",
5758
5971
  ...rows,
5972
+ ...contextLines.length ? ["", ...contextLines] : [],
5759
5973
  ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5760
5974
  "",
5761
5975
  `[Open the dashboard](${s.dashboard_url})`,
@@ -9591,6 +9805,14 @@ ${outcome.summary}`);
9591
9805
  ...api.fetch ? { fetch: api.fetch } : {}
9592
9806
  }).catch(infra(`results are saved in ${out}, but the upload failed`));
9593
9807
  rt.print(`uploaded ${up.uploaded} runs to ${api.url}${up.skipped.length ? ` (${up.skipped.length} skipped: ${up.skipped.map((x) => `${x.run_id} ${x.why}`).join(", ")})` : ""}`);
9808
+ if (up.uploaded === 0) {
9809
+ if (runSet)
9810
+ await completeRunSet(api, runSet.run_set_id, "on-read").catch(infra("no run was uploaded, and the run set could not be closed"));
9811
+ rt.print("Blocked: no run sent any data. Check that the build carries the SDK and that the app reaches the API.");
9812
+ if (runSet)
9813
+ await writeResult(deps, out, rt, runSet, null, git.branch, true);
9814
+ return EXIT.infra;
9815
+ }
9594
9816
  if (!runSet)
9595
9817
  return EXIT.ok;
9596
9818
  let summary = null;
@@ -9607,6 +9829,9 @@ ${formatRunSet(summary)}`);
9607
9829
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
9608
9830
  return EXIT.worse;
9609
9831
  }
9832
+ const unchecked = o["fail-on-worse"] && summary ? uncheckedGate(summary) : null;
9833
+ if (unchecked)
9834
+ rt.print(unchecked);
9610
9835
  return EXIT.ok;
9611
9836
  }
9612
9837
  async function replayFlow(config, scenario) {
@@ -9623,13 +9848,16 @@ var infra = (what) => (e) => {
9623
9848
  throw new Error(`${what}: ${e.message}`);
9624
9849
  throw e;
9625
9850
  };
9626
- async function writeResult(deps, out, rt, runSet, summary, branch) {
9851
+ async function writeResult(deps, out, rt, runSet, summary, branch, blocked = false) {
9627
9852
  const url = summary?.dashboard_url ?? runSet.dashboard_url;
9628
- await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: summary?.verdicts?.state ?? null, summary }, null, 2)}
9853
+ await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: blocked ? "blocked" : summary?.verdicts?.state ?? null, summary }, null, 2)}
9629
9854
  `);
9630
9855
  const step = rt.env.GITHUB_STEP_SUMMARY;
9631
9856
  if (step)
9632
- await deps.fs.appendFile(step, markdownRunSet(summary, { dashboardUrl: url, branch }));
9857
+ await deps.fs.appendFile(step, blocked ? `### KeepSnappy: Blocked
9858
+
9859
+ No run sent any data. [Open the dashboard](${url})
9860
+ ` : markdownRunSet(summary, { dashboardUrl: url, branch }));
9633
9861
  }
9634
9862
 
9635
9863
  // apps/agent/src/doctor.ts
@@ -10091,7 +10319,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10091
10319
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10092
10320
  import { fileURLToPath } from "node:url";
10093
10321
  function driverVersion() {
10094
- const built = "0.1.0-alpha.16";
10322
+ const built = "0.1.0-alpha.17";
10095
10323
  if (built)
10096
10324
  return built;
10097
10325
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10146,8 +10374,6 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
10146
10374
  var FLUSH_MARGIN_MS = 30000;
10147
10375
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10148
10376
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
10149
- // packages/appium-driver/dist/src/gestures.js
10150
- var densities = new WeakMap;
10151
10377
  // apps/agent/src/device-model.ts
10152
10378
  import { tmpdir } from "node:os";
10153
10379
  import { join as join12 } from "node:path";
@@ -10317,6 +10543,7 @@ function slackMessage(s, ctx) {
10317
10543
  const better = items.filter(isBetter).sort(bySize);
10318
10544
  const commit = commitLine(ctx);
10319
10545
  const heated = v.scenarios.filter((sc) => heatNote(sc));
10546
+ const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", phone(sc))).map(context);
10320
10547
  const heat = heated.map((sc) => context(heatNote(sc)));
10321
10548
  const errorRows = worse.filter((i) => i.grade !== "warn");
10322
10549
  const warningRows = worse.filter((i) => i.grade === "warn");
@@ -10330,15 +10557,15 @@ function slackMessage(s, ctx) {
10330
10557
  if (!worse.length) {
10331
10558
  if (!heat.length)
10332
10559
  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>`)];
10560
+ const head = `⚪ ${ctx.branch}: ${heat.length ? INFO_WORDS.heat : v.word ?? STATE_WORDS["no-change"]}`;
10561
+ const blocks = [section(`*${head}*`), ...heat, ...contextLines, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0]?.dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10335
10562
  return { text: head, attachments: [{ color: COLOR.none, blocks }] };
10336
10563
  }
10337
10564
  const graded = worse.some((i) => i.grade);
10338
10565
  const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
10339
10566
  const counts = graded ? [errors ? `${places(STATE_WORDS.worse, errors)}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? places(STATE_WORDS.warning, warns) : ""].filter(Boolean).join(", ") : `${places(STATE_WORDS.worse, placeCount(worse))}${notBlocking ? ` (${NOT_BLOCKING})` : ""}`;
10340
10567
  const head = `${worseMark} ${ctx.branch}: ${counts}`;
10341
- const blocks = [section(`*${head}*`)];
10568
+ const blocks = [section(`*${head}*`), ...contextLines];
10342
10569
  const groups = new Map;
10343
10570
  for (const i of worse)
10344
10571
  groups.set(scenarioOf(i), [...groups.get(scenarioOf(i)) ?? [], i]);
@@ -10373,7 +10600,7 @@ ${prTable(rows, sc)}`));
10373
10600
  const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
10374
10601
  const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
10375
10602
  const blocks = [section(`*${head}*
10376
- ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
10603
+ ${journeys} journey${journeys === 1 ? "" : "s"} checked`), ...contextLines];
10377
10604
  let shown = 0;
10378
10605
  for (const [title, list] of [[STATE_WORDS.worse, errorRows], [STATE_WORDS.warning, warningRows], [STATE_WORDS.better, better]]) {
10379
10606
  for (const i of list) {
@@ -10409,17 +10636,23 @@ var LINKS_USAGE = `keepsnappy activation-link --scheme <app://> --token <run tok
10409
10636
  flush-link --scheme <app://>
10410
10637
  gc-link --scheme <app://> [--n 1]`;
10411
10638
  function linksCommand(cmd, argv, rt) {
10412
- const { values: o } = parseArgs({
10413
- args: [...argv],
10414
- options: {
10415
- scheme: { type: "string" },
10416
- token: { type: "string" },
10417
- n: { type: "string" },
10418
- "no-diag": { type: "boolean" },
10419
- "no-profile": { type: "boolean" },
10420
- set: { type: "string", multiple: true }
10639
+ const { values: o } = (() => {
10640
+ try {
10641
+ return parseArgs({
10642
+ args: [...argv],
10643
+ options: {
10644
+ scheme: { type: "string" },
10645
+ token: { type: "string" },
10646
+ n: { type: "string" },
10647
+ "no-diag": { type: "boolean" },
10648
+ "no-profile": { type: "boolean" },
10649
+ set: { type: "string", multiple: true }
10650
+ }
10651
+ });
10652
+ } catch (e) {
10653
+ throw new UsageError([e.message, LINKS_USAGE]);
10421
10654
  }
10422
- });
10655
+ })();
10423
10656
  if (!o.scheme)
10424
10657
  throw new ConfigError([`--scheme is required (the app's URL scheme, e.g. myapp://)`]);
10425
10658
  if (cmd === "flush-link") {
@@ -10865,9 +11098,9 @@ ${tail.join(" ")}
10865
11098
  ...blocked ? { blocked: { reason: `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.` } } : {},
10866
11099
  always: envValue(rt.env, "SLACK_ALWAYS") === "1"
10867
11100
  });
10868
- if (!msg)
10869
- rt.print(f.wait ? `Slack: nothing to report (${STATE_WORDS["no-change"].toLowerCase()})` : "Slack: skipped, the message needs --wait");
10870
- else {
11101
+ if (!msg) {
11102
+ rt.print(!f.wait ? "Slack: skipped, the message needs --wait" : notComputed(summary) ? "Slack: nothing to report, the verdict is not computed yet" : `Slack: nothing to report (${resultWord(summary).toLowerCase()})`);
11103
+ } else {
10871
11104
  const r = await postToSlack(hook, msg, rt.fetch ?? fetch);
10872
11105
  rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
10873
11106
  }
@@ -10878,6 +11111,9 @@ ${tail.join(" ")}
10878
11111
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
10879
11112
  return EXIT.worse;
10880
11113
  }
11114
+ const unchecked = f["fail-on-worse"] ? uncheckedGate(summary) : null;
11115
+ if (unchecked)
11116
+ rt.print(unchecked);
10881
11117
  return EXIT.ok;
10882
11118
  }
10883
11119
 
@@ -11297,7 +11533,7 @@ function printPlan(p, f, rt) {
11297
11533
  line(` env ${k}=${substitute(v, {})}`);
11298
11534
  line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
11299
11535
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
11300
- line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse");
11536
+ line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)");
11301
11537
  }
11302
11538
  var ANNOTATION = /^::(error|warning|notice)( [^:]*)?::/;
11303
11539
  async function runDriver2(deps, rt, f, p, ports, ctx) {
@@ -11729,7 +11965,8 @@ var SHORT_FORM_NOTES = {
11729
11965
  main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
11730
11966
  place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
11731
11967
  window_history: `the runs older than the newest ${SHORT_POINTS}`,
11732
- pr_status: "the per-journey dashboard links and the heat note"
11968
+ pr_status: "the per-journey dashboard links and the heat note",
11969
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
11733
11970
  };
11734
11971
  var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
11735
11972
 
@@ -11748,6 +11985,13 @@ class Rows {
11748
11985
  full(...texts) {
11749
11986
  return this.add(false, ...texts);
11750
11987
  }
11988
+ detail(...texts) {
11989
+ for (const text of texts)
11990
+ for (const line of text.split(`
11991
+ `))
11992
+ this.rows.push({ text: line, short: false, notice: false });
11993
+ return this;
11994
+ }
11751
11995
  render(form, left = []) {
11752
11996
  if (form !== "short")
11753
11997
  return this.rows.map((r) => r.text).join(`
@@ -11762,7 +12006,7 @@ class Rows {
11762
12006
  }
11763
12007
  if (kept[kept.length - 1] === "")
11764
12008
  kept.pop();
11765
- if (this.rows.some((r) => !r.short && r.text.trim() !== ""))
12009
+ if (this.rows.some((r) => !r.short && r.notice !== false && r.text.trim() !== ""))
11766
12010
  kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
11767
12011
  return kept.join(`
11768
12012
  `);
@@ -11770,19 +12014,22 @@ class Rows {
11770
12014
  }
11771
12015
  var LABEL_W = 14;
11772
12016
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
11773
- var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
12017
+ var plural2 = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
11774
12018
  var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
11775
12019
  function formatFinding(f, opts = {}) {
11776
12020
  const r = new Rows;
11777
12021
  r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
12022
+ if (f.lane)
12023
+ r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
11778
12024
  r.full(line("Why", f.answer.why));
11779
12025
  r.both(line("What to do", f.answer.what_to_do));
11780
12026
  r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
11781
12027
  r.both(line("Id", f.id));
11782
12028
  if (f.why.components.length) {
11783
12029
  r.full(line("Components", ""));
11784
- for (const c of f.why.components) {
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)"}`);
12030
+ const names = componentReadNames(f.why.components.map((c) => ({ key: c.key, name: c.component, ...c.file ? { file: c.file } : {} })));
12031
+ for (const [i, c] of f.why.components.entries()) {
12032
+ r.full(` ${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms ${c.key ? READ_WORDS.componentTime : READ_WORDS.ownTime}, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
11786
12033
  }
11787
12034
  }
11788
12035
  const memory = f.why.memory ?? [];
@@ -11797,15 +12044,15 @@ function formatFinding(f, opts = {}) {
11797
12044
  r.full("", promptText(f));
11798
12045
  return r.render(opts.form, [
11799
12046
  "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")] : [],
12047
+ ...f.why.components.length ? [plural2(f.why.components.length, "component line")] : [],
12048
+ ...memory.some((m) => m.metric !== f.what.metric) ? [plural2(memory.filter((m) => m.metric !== f.what.metric).length, "memory evidence line")] : [],
11802
12049
  ...opts.prompt ? ["the prompt"] : []
11803
12050
  ]);
11804
12051
  }
11805
12052
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
11806
12053
  var LANES_SHOWN = 8;
11807
12054
  var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
11808
- function measuredLines(l, r) {
12055
+ function measuredLines(l, r, opts) {
11809
12056
  const f = l.filters;
11810
12057
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
11811
12058
  const m = l.measured;
@@ -11813,7 +12060,7 @@ function measuredLines(l, r) {
11813
12060
  if (!n) {
11814
12061
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
11815
12062
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
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.`);
12063
+ r.both(m.run_sets ? `${scope}: ${plural2(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
11817
12064
  return [];
11818
12065
  }
11819
12066
  const lane = (x) => {
@@ -11821,22 +12068,29 @@ function measuredLines(l, r) {
11821
12068
  return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
11822
12069
  };
11823
12070
  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.`);
12071
+ r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
12072
+ r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
11825
12073
  return [];
11826
12074
  }
11827
12075
  const shown = m.lanes.slice(0, LANES_SHOWN);
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)}`);
12076
+ r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12077
+ for (const x of shown) {
12078
+ const keep = !x.judged || !!x.context;
12079
+ r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12080
+ r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
12081
+ }
11832
12082
  if (m.lanes.length > shown.length)
11833
12083
  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"] : []];
12084
+ const judgedLeft = shown.filter((x) => x.judged && !x.context).length;
12085
+ return [
12086
+ ...judgedLeft ? [plural2(judgedLeft, "judged lane")] : [],
12087
+ ...m.lanes.length > shown.length ? [plural2(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []
12088
+ ];
11835
12089
  }
11836
12090
  function countsLine2(l) {
11837
12091
  const c = l.counts;
11838
12092
  const parts = [
11839
- c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
12093
+ c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} ${NOT_BLOCKING})` : ""}` : "",
11840
12094
  c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
11841
12095
  c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
11842
12096
  ].filter(Boolean);
@@ -11845,7 +12099,7 @@ function countsLine2(l) {
11845
12099
  const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
11846
12100
  const heated = c.heat_runs ?? 0;
11847
12101
  const more = [
11848
- heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
12102
+ heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (run health, layer 3)` : "",
11849
12103
  state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
11850
12104
  ].filter(Boolean);
11851
12105
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
@@ -11853,7 +12107,7 @@ function countsLine2(l) {
11853
12107
  }
11854
12108
  function formatFindingList(l, opts = {}) {
11855
12109
  const r = new Rows;
11856
- const lanesLeft = measuredLines(l, r);
12110
+ const lanesLeft = measuredLines(l, r, opts);
11857
12111
  if (l.measured.newest || l.total)
11858
12112
  r.both(countsLine2(l));
11859
12113
  l.findings.forEach((f, i) => {
@@ -11870,11 +12124,7 @@ function formatFindingList(l, opts = {}) {
11870
12124
  if (l.findings.length)
11871
12125
  r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
11872
12126
  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
- ]);
12127
+ return r.render(opts.form, [...l.findings.length ? [plural2(l.findings.length, "how-sure line")] : [], ...notWorse ? [plural2(notWorse, "place line")] : [], ...lanesLeft]);
11878
12128
  }
11879
12129
  var STATUS_LEAD = {
11880
12130
  ok: "ok",
@@ -11890,9 +12140,10 @@ function formatBranchStatus(s, opts = {}) {
11890
12140
  if (s.lanes.length)
11891
12141
  r.both("");
11892
12142
  for (const l of s.lanes) {
11893
- const badge = l.result === "blocks" ? " (blocks)" : l.word === STATE_WORDS.worse ? ` (${NOT_BLOCKING})` : "";
12143
+ const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
11894
12144
  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)}` : ""}`);
12145
+ r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
12146
+ r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
11896
12147
  for (const f of l.worst)
11897
12148
  r.both(` ${f.id} ${f.headline}`);
11898
12149
  if (l.present_findings > l.worst.length)
@@ -11903,8 +12154,13 @@ function formatBranchStatus(s, opts = {}) {
11903
12154
  const links = s.lanes.filter((l) => l.link).length;
11904
12155
  const heated = s.counts.heat_runs ?? 0;
11905
12156
  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` : ""],
12157
+ [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
12158
+ [
12159
+ true,
12160
+ s.not_judged_findings ? `${plural2(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""
12161
+ ],
12162
+ [true, statusProblemsLine(s)],
12163
+ [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
11908
12164
  [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
11909
12165
  [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
11910
12166
  ];
@@ -11913,10 +12169,65 @@ function formatBranchStatus(s, opts = {}) {
11913
12169
  r.both("");
11914
12170
  for (const [short, text] of shownMore)
11915
12171
  r.add(short, text);
11916
- return r.render(opts.form, [...links ? [plural(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
12172
+ return r.render(opts.form, [...links ? [plural2(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
12173
+ }
12174
+ function statusProblemsLine(s) {
12175
+ const p = s.problems;
12176
+ if (!p?.count)
12177
+ return "";
12178
+ return `${plural2(p.places, "place")} over budget in this branch’s own runs (${p.far_count ? `${p.far_count} far over budget; ` : ""}not a regression, never blocks): list_problems, or keepsnappy problems --branch ${s.branch}`;
12179
+ }
12180
+ var unreadWords = (n) => `${n} journey × phone not read`;
12181
+ var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12182
+ function formatProblemList(l, opts = {}) {
12183
+ const r = new Rows;
12184
+ const b = l.filters.branch;
12185
+ const c = l.counts;
12186
+ const unread = c.unread_lanes ?? 0;
12187
+ const read = l.lanes.length - unread;
12188
+ if (!l.measured.run_sets) {
12189
+ r.both(`No KeepSnappy measurement on ${b === l.baseline_branch ? b : `branch ${b}`}: nothing is known about its problems.`);
12190
+ return r.render(opts.form);
12191
+ }
12192
+ if (!l.lanes.length) {
12193
+ r.both(`${b}: ${plural2(l.measured.run_sets, "run set")} measured, but no judgement yet to read problems from.`);
12194
+ return r.render(opts.form);
12195
+ }
12196
+ if (c.count)
12197
+ r.both(`${b}: ${plural2(c.places, "place")} over budget in ${b === l.baseline_branch ? "main’s" : "this branch’s"} own runs${c.far_count ? ` (${c.far_count} far over budget)` : ""}`);
12198
+ else if (read)
12199
+ r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12200
+ else
12201
+ r.both(`${b}: no problem could be read: ${unread} journey × phone has a stored result that is not current, so nothing is known about its problems.`);
12202
+ r.both(l.note);
12203
+ if (read && unread)
12204
+ r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
12205
+ l.problems.forEach((p, i) => {
12206
+ const keep = i < SHORT_PLACES;
12207
+ r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
12208
+ r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
12209
+ const top = p.top_components[0];
12210
+ const topName = componentReadNames(p.top_components)[0];
12211
+ const detail = [p.work, top ? `heaviest ${topName}${opts.verbose && top.key ? ` (${top.key})` : ""} ${top.self_text}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
12212
+ if (detail.length)
12213
+ r.full(` ${detail.join("; ")}`);
12214
+ if (p.url)
12215
+ r.full(` ${p.url}`);
12216
+ });
12217
+ const shown = l.problems.length ? `Showing ${l.offset + 1}–${l.offset + l.problems.length} of ${l.total}` : "";
12218
+ if (l.next_offset !== null)
12219
+ r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
12220
+ else if (l.offset > 0)
12221
+ r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
12222
+ const detailLines = l.problems.some((p) => p.work || p.top_components.length || p.new_component || p.range_text);
12223
+ return r.render(opts.form, [
12224
+ ...detailLines ? ["the work and component lines"] : [],
12225
+ ...l.problems.some((p) => p.url) ? ["the dashboard links"] : [],
12226
+ ...l.problems.length > SHORT_PLACES ? [`${plural2(l.problems.length - SHORT_PLACES, "problem")} beyond the fifth`] : []
12227
+ ]);
11917
12228
  }
11918
12229
  var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
11919
- function formatCompare(r) {
12230
+ function formatCompare(r, opts = {}) {
11920
12231
  const out = [
11921
12232
  `Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`,
11922
12233
  `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`,
@@ -11926,9 +12237,42 @@ function formatCompare(r) {
11926
12237
  out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
11927
12238
  for (const w of r.windows) {
11928
12239
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
11929
- if (!ms.length)
12240
+ const c = w.component_page ?? w.components;
12241
+ if (!ms.length && !c)
11930
12242
  continue;
11931
- out.push("", w.window_name);
12243
+ out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
12244
+ const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
12245
+ const components = [...c?.rows ?? [], ...singles];
12246
+ const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
12247
+ const shown = new Map(components.map((row, i) => [row.key, names[i]]));
12248
+ for (const row of c?.rows ?? [])
12249
+ out.push(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
12250
+ for (const [label, rows] of [
12251
+ [READ_WORDS.onlyB, c?.added ?? []],
12252
+ [READ_WORDS.onlyA, c?.removed ?? []]
12253
+ ]) {
12254
+ for (const row of rows)
12255
+ out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
12256
+ }
12257
+ const page = w.component_page;
12258
+ if (page) {
12259
+ for (const side of ["a", "b"]) {
12260
+ if (!page.measured[side])
12261
+ out.push(` ${notMeasuredText(side)}`);
12262
+ for (const row of page.uncompared?.[side] ?? [])
12263
+ out.push(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
12264
+ }
12265
+ out.push(` ${componentPageText(page.page, components.length)}`);
12266
+ } else if (c?.totals)
12267
+ out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
12268
+ if (c?.omissions)
12269
+ for (const side of ["a", "b"]) {
12270
+ if (page && !page.measured[side])
12271
+ continue;
12272
+ const o = c.omissions[side];
12273
+ if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
12274
+ out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
12275
+ }
11932
12276
  for (const m of ms)
11933
12277
  out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
11934
12278
  }
@@ -11937,7 +12281,7 @@ function formatCompare(r) {
11937
12281
  }
11938
12282
  function formatHistory(h, opts = {}) {
11939
12283
  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"}`);
12284
+ r.both(`${h.window_name}${opts.verbose ? ` (${h.window})` : ""} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural2(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
11941
12285
  const ms = h.measures.slice(0, 4);
11942
12286
  if (h.points.length) {
11943
12287
  r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
@@ -11948,11 +12292,11 @@ function formatHistory(h, opts = {}) {
11948
12292
  if (h.findings.length)
11949
12293
  r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
11950
12294
  const older = Math.max(0, h.points.length - SHORT_POINTS);
11951
- return r.render(opts.form, older ? [plural(older, "older run")] : []);
12295
+ return r.render(opts.form, older ? [plural2(older, "older run")] : []);
11952
12296
  }
11953
12297
  function formatCommits(c) {
11954
12298
  return [
11955
- `${plural(c.commits.length, "measured commit")} (${plural(c.sessions, "session")}) on ${c.branch}, oldest first:`,
12299
+ `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
11956
12300
  ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
11957
12301
  c.note
11958
12302
  ].join(`
@@ -11994,8 +12338,39 @@ function formatSessionPlaces(p, opts = {}) {
11994
12338
  p.places.forEach((place, i) => {
11995
12339
  const m = place.measures.find((x) => x.measure === by);
11996
12340
  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}`);
12341
+ const names = componentReadNames(place.components);
12342
+ const comps = place.components.map((c, i) => `${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural2(c.renders_median, "render")}` : ""})`);
12343
+ r.both(`${p.offset + i + 1}. ${place.window_name}${opts.verbose ? ` (${place.window})` : ""}: ${value}`);
12344
+ if (place.components_page)
12345
+ r.detail(` ${componentPageText(place.components_page, place.components.length)}`);
12346
+ for (const o of (place.omissions ?? []).filter((o) => o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0))
12347
+ r.detail(` ${omissionText(o)}`);
12348
+ const slowest = place.measures.find((m) => m.measure === "commit_max_ms");
12349
+ if (by !== "commit_max_ms" && slowest)
12350
+ r.detail(` ${READ_WORDS.slowest}: ${fieldValueText("commit_max_ms", slowest.median)}`);
12351
+ for (const detail of place.details ?? []) {
12352
+ if (detail.slowestCommit)
12353
+ r.detail(` ${detail.run_id}: ${READ_WORDS.slowest}: ${exactComponentMs(detail.slowestCommit.ms)}`);
12354
+ r.detail(` ${detail.run_id}: ${OUTSIDE_APP_NAME}: ${detail.outside ? exactComponentMs(detail.outside.selfMs) : "–"}; ${OTHER_RENDER_WORK_NAME}: ${detail.remainderMs === null ? "–" : exactComponentMs(detail.remainderMs)}`);
12355
+ for (const l of detail.outside?.rolledUp ?? [])
12356
+ r.detail(` ${libraryLineText(l)}`);
12357
+ if (detail.outside?.rolledUpRest)
12358
+ r.detail(` ${rolledUpRestText(detail.outside.rolledUpRest)}`);
12359
+ }
12360
+ for (const [ci, c] of place.components.entries()) {
12361
+ if (c.detail === "ms-only" && !c.runs?.length)
12362
+ r.detail(` ${names[ci]}: ${READ_WORDS.detailUnavailable}`);
12363
+ for (const d of c.runs ?? [])
12364
+ if (d.detail === "ms-only")
12365
+ r.detail(` ${names[ci]} · ${d.run_id}: ${d.selfMs === null ? "–" : exactComponentMs(d.selfMs)}; ${READ_WORDS.detailUnavailable}`);
12366
+ for (const d of c.runs ?? [])
12367
+ if (d.detail === "full") {
12368
+ if (d.parent)
12369
+ r.detail(` ${names[ci]} · ${d.run_id}: ${READ_WORDS.parent} ${componentReadName({ key: d.parent, name: d.parent })}`);
12370
+ if (d.rolledUpMs !== undefined && d.selfMs !== null)
12371
+ r.detail(` ${names[ci]} · ${d.run_id}: ${rolledUpText(d.rolledUpMs, d.selfMs)}`, ...(d.rolledUp ?? []).map((l) => ` ${libraryLineText(l)}`), ...d.rolledUpRest ? [` ${rolledUpRestText(d.rolledUpRest)}`] : []);
12372
+ }
12373
+ }
11999
12374
  if (comps.length) {
12000
12375
  r.full(` Components: ${list(comps)}`);
12001
12376
  componentLines++;
@@ -12005,7 +12380,7 @@ function formatSessionPlaces(p, opts = {}) {
12005
12380
  r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
12006
12381
  else if (p.next_offset !== null)
12007
12382
  r.both("", `Showing ${p.offset + 1}–${p.offset + p.places.length} of ${p.total}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).`);
12008
- return r.render(opts.form, componentLines ? [plural(componentLines, "component line")] : []);
12383
+ return r.render(opts.form, componentLines ? [plural2(componentLines, "component line")] : []);
12009
12384
  }
12010
12385
  function formatLayers(l) {
12011
12386
  return [
@@ -12027,7 +12402,7 @@ function formatMainSessions(h, opts = {}) {
12027
12402
  let otherChanges = 0;
12028
12403
  for (const s of h.sessions) {
12029
12404
  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")})`);
12405
+ r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural2(s.runs, "run")})`);
12031
12406
  if (!s.places)
12032
12407
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
12033
12408
  else
@@ -12039,13 +12414,16 @@ function formatMainSessions(h, opts = {}) {
12039
12414
  for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
12040
12415
  r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
12041
12416
  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 ?? "–"}`);
12417
+ r.add(s === latest, ` Main change: ${windowQualified(c.place)}${opts.verbose ? ` (${c.place}; ${c.ref})` : ""} ${storedRefWords(c.ref, h.changes.filter((x) => x.place === c.place && x.from_run_set_id === c.from_run_set_id).map((x) => x.ref))}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
12043
12418
  if (k && s !== latest)
12044
12419
  otherChanges++;
12045
12420
  }
12046
12421
  }
12047
12422
  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"] : []]);
12423
+ return r.render(opts.form, [
12424
+ ...older ? [plural2(older, "older session")] : [],
12425
+ ...otherChanges ? [plural2(otherChanges, "main change") + " of the other recent sessions"] : []
12426
+ ]);
12049
12427
  }
12050
12428
  function formatMainSessionPlaces(p, opts = {}) {
12051
12429
  const r = new Rows;
@@ -12054,12 +12432,12 @@ function formatMainSessionPlaces(p, opts = {}) {
12054
12432
  const baselineSessions = p.baseline_sessions;
12055
12433
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
12056
12434
  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 } })}` : ""}`);
12435
+ r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, p.places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
12058
12436
  const inNorm = p.places.filter((x) => x.tier === "same").length;
12059
- return r.render(opts.form, inNorm ? [plural(inNorm, "place") + " in norm"] : []);
12437
+ return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
12060
12438
  }
12061
12439
  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 ?? "–"}`;
12440
+ var previousLine = (p, verbose = false, peers = []) => ` ${windowQualified(p.place)}${verbose ? ` (${p.place}; ${p.ref})` : ""} ${storedRefWords(p.ref, peers)}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status === "gone" ? p.ref.startsWith("b:component:") ? JOURNEY_ROW_WORDS.removed : JOURNEY_ROW_WORDS.notInRun : p.status === "new" ? JOURNEY_ROW_WORDS.new : p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
12063
12441
  function formatMainVsPrevious(m, opts = {}) {
12064
12442
  const r = new Rows().both(`Information only: main session ${m.run_set_id} vs ${m.previous_run_set_id ?? "no previous session"}.`, `Measure: ${m.measure}; ${m.status}.`);
12065
12443
  if (m.cut === "unknown")
@@ -12071,12 +12449,12 @@ function formatMainVsPrevious(m, opts = {}) {
12071
12449
  if (m.stale.length)
12072
12450
  r.both(`stale: ${m.stale.join(", ")}`);
12073
12451
  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)));
12452
+ r.both("Whole journey:", previousLine(m.journey, opts.verbose));
12453
+ m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p, opts.verbose, m.places.filter((row) => row.place === p.place).map((row) => row.ref))));
12076
12454
  if (m.next_offset !== null)
12077
12455
  r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
12078
12456
  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")] : []);
12457
+ return r.render(opts.form, smaller ? [plural2(smaller, "smaller mover")] : []);
12080
12458
  }
12081
12459
  function formatPlaceTrend(t, opts = {}) {
12082
12460
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
@@ -12099,7 +12477,7 @@ function formatPlaceTrend(t, opts = {}) {
12099
12477
  const unit = place.unit ? ` ${place.unit}` : "";
12100
12478
  if (!k)
12101
12479
  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}`);
12480
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
12103
12481
  }
12104
12482
  }
12105
12483
  if (!t.sessions.length)
@@ -12108,7 +12486,10 @@ function formatPlaceTrend(t, opts = {}) {
12108
12486
  r.both(`Not computed: ${t.not_computed.join(", ")}`);
12109
12487
  if (t.stale.length)
12110
12488
  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")] : []]);
12489
+ return r.render(opts.form, [
12490
+ ...olderSessions.size ? [plural2(olderSessions.size, "older session")] : [],
12491
+ ...olderRestarts ? [plural2(olderRestarts, "older restart marker")] : []
12492
+ ]);
12112
12493
  }
12113
12494
 
12114
12495
  // packages/mcp/dist/src/client.js
@@ -12133,7 +12514,13 @@ async function get(t, path, query = {}) {
12133
12514
  }
12134
12515
  const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
12135
12516
  const text = await r.text();
12136
- const body = text ? JSON.parse(text) : {};
12517
+ let body;
12518
+ try {
12519
+ body = text ? JSON.parse(text) : {};
12520
+ } catch {
12521
+ const page = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
12522
+ throw new ApiError(r.status, "not_json", `the API answered ${r.status} with a body that is not JSON${page ? `: ${page}` : ""}`);
12523
+ }
12137
12524
  if (!r.ok) {
12138
12525
  const code = typeof body.error === "string" ? body.error : String(r.status);
12139
12526
  const message = typeof body.message === "string" ? body.message : code;
@@ -12144,8 +12531,9 @@ async function get(t, path, query = {}) {
12144
12531
  }
12145
12532
  var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, state: f.state, limit: f.limit, offset: f.offset || undefined });
12146
12533
  var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
12534
+ var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
12147
12535
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
12148
- var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
12536
+ var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
12149
12537
  var windowHistory = (t, q) => get(t, "v1/history", q);
12150
12538
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
12151
12539
  var layers = (t) => get(t, "v1/layers");
@@ -12159,7 +12547,10 @@ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
12159
12547
  window: f.window,
12160
12548
  limit: f.limit,
12161
12549
  offset: f.offset || undefined,
12162
- top_components: f.top_components
12550
+ top_components: f.top_components,
12551
+ detail: f.detail,
12552
+ component_offset: f.component_offset,
12553
+ component_limit: f.component_limit
12163
12554
  });
12164
12555
  var SESSION_SORTS = ["median", "p90", "mad"];
12165
12556
  var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
@@ -12173,6 +12564,8 @@ var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
12173
12564
  // apps/agent/src/findings.ts
12174
12565
  var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--short] [--json]
12175
12566
  status --branch NAME [--short] [--json] (is my PR ok? one answer: blocked / nothing blocks / ok, per journey × phone the word, the ids of the worst findings, dashboard links; never measured and not judged are not ok)
12567
+ problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
12568
+ (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
12176
12569
  finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
12177
12570
  compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
12178
12571
  history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
@@ -12183,20 +12576,14 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
12183
12576
  layers [--json]
12184
12577
  trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
12185
12578
  (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)
12579
+ ${READ_USAGE.components}
12580
+ ${READ_USAGE.verbose}
12581
+ ${READ_USAGE.detailFlag}
12582
+ --short (findings, status, problems, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
12187
12583
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
12188
-
12189
- class UsageError extends ConfigError {
12190
- constructor(lines) {
12191
- super(lines);
12192
- this.message = lines.join(`
12193
- `);
12194
- this.name = "UsageError";
12195
- }
12196
- }
12197
12584
  var STATES = ["present", "not-judged", "resolved", "all"];
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"]);
12585
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
12586
+ var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
12200
12587
  async function findingsCommand(cmd, argv, rt) {
12201
12588
  const args = (() => {
12202
12589
  try {
@@ -12204,6 +12591,10 @@ async function findingsCommand(cmd, argv, rt) {
12204
12591
  args: argv,
12205
12592
  allowPositionals: true,
12206
12593
  options: {
12594
+ verbose: { type: "boolean" },
12595
+ detail: { type: "boolean" },
12596
+ page: { type: "string" },
12597
+ all: { type: "boolean" },
12207
12598
  branch: { type: "string" },
12208
12599
  main: { type: "boolean" },
12209
12600
  journey: { type: "string" },
@@ -12237,7 +12628,7 @@ async function findingsCommand(cmd, argv, rt) {
12237
12628
  if (!key)
12238
12629
  throw new ConfigError([`a project key with read scope is needed: set the environment variable ${keyEnv}`]);
12239
12630
  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" };
12631
+ const answerForm = { form: o.short ? "short" : "full", verbose: o.verbose };
12241
12632
  const out = (v, text) => {
12242
12633
  rt.print(o.json ? JSON.stringify(v, null, 2) : text(v, answerForm));
12243
12634
  return 0;
@@ -12254,6 +12645,11 @@ async function findingsCommand(cmd, argv, rt) {
12254
12645
  const offset = o.offset === undefined ? undefined : Number(o.offset);
12255
12646
  if (offset !== undefined && (!Number.isInteger(offset) || offset < 0))
12256
12647
  throw new UsageError(["--offset must be a whole number >= 0"]);
12648
+ const page = o.page === undefined ? undefined : Number(o.page);
12649
+ if (page !== undefined && (!["places", "compare"].includes(cmd) || !Number.isInteger(page) || page < 1))
12650
+ throw new UsageError([READ_USAGE.page]);
12651
+ if (o.all && (!["places", "compare"].includes(cmd) || page !== undefined || o.offset !== undefined || o.short))
12652
+ throw new UsageError([READ_USAGE.all]);
12257
12653
  switch (cmd) {
12258
12654
  case "main": {
12259
12655
  if (!o.journey)
@@ -12292,6 +12688,19 @@ async function findingsCommand(cmd, argv, rt) {
12292
12688
  if (!o.branch)
12293
12689
  throw new UsageError(["keepsnappy status needs --branch NAME", FINDINGS_USAGE]);
12294
12690
  return out(await branchStatus(t, o.branch), formatBranchStatus);
12691
+ case "problems":
12692
+ if (!o.branch && !o.main)
12693
+ throw new UsageError(["keepsnappy problems needs --branch NAME or --main", FINDINGS_USAGE]);
12694
+ if (o.branch && o.main)
12695
+ throw new UsageError(["give --branch or --main, not both"]);
12696
+ return out(await listProblems(t, {
12697
+ ...o.branch ? { branch: o.branch } : {},
12698
+ ...o.main ? { main: true } : {},
12699
+ ...o.journey ? { journey: o.journey } : {},
12700
+ ...o.phone ? { phone: o.phone } : {},
12701
+ ...limit ? { limit } : {},
12702
+ ...offset ? { offset } : {}
12703
+ }), formatProblemList);
12295
12704
  case "finding": {
12296
12705
  need(1, "<id>");
12297
12706
  const f = await getFinding(t, p[0]);
@@ -12301,9 +12710,15 @@ async function findingsCommand(cmd, argv, rt) {
12301
12710
  }
12302
12711
  return out(f, formatFinding);
12303
12712
  }
12304
- case "compare":
12713
+ case "compare": {
12305
12714
  need(2, "<runA> <runB>");
12306
- return out(await compareRuns(t, p[0], p[1]), formatCompare);
12715
+ const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
12716
+ if (!Number.isInteger(size) || size < 1 || size > 200)
12717
+ throw new UsageError([READ_USAGE.compareTop]);
12718
+ const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
12719
+ const compared = await compareRuns(t, p[0], p[1], query);
12720
+ return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
12721
+ }
12307
12722
  case "history":
12308
12723
  need(2, "<journey> <window>");
12309
12724
  return out(await windowHistory(t, { journey: p[0], window: p.slice(1).join(" "), ...o.phone ? { phone: o.phone } : {}, ...limit ? { limit } : {} }), formatHistory);
@@ -12315,9 +12730,12 @@ async function findingsCommand(cmd, argv, rt) {
12315
12730
  if (sort && !SESSION_SORTS.includes(sort))
12316
12731
  throw new UsageError([`--sort is ${SESSION_SORTS.join(", ")}`]);
12317
12732
  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, {
12733
+ if (top !== undefined && (!Number.isInteger(top) || top < (page || o.all ? 1 : 0) || top > (page || o.all ? 200 : 20)))
12734
+ throw new UsageError([READ_USAGE.top]);
12735
+ if ((o.window?.length ?? 0) > 1)
12736
+ throw new UsageError(["keepsnappy places takes one --window (more windows: keepsnappy trend)", FINDINGS_USAGE]);
12737
+ const query = {
12738
+ ...o.verbose || o.detail ? { detail: true } : {},
12321
12739
  ...o["run-set"] ? { run_set: o["run-set"] } : {},
12322
12740
  ...o.journey ? { journey: o.journey } : {},
12323
12741
  ...o.phone ? { phone: o.phone } : {},
@@ -12327,8 +12745,9 @@ async function findingsCommand(cmd, argv, rt) {
12327
12745
  ...o.window?.length ? { window: o.window[0] } : {},
12328
12746
  ...limit ? { limit } : {},
12329
12747
  ...offset ? { offset } : {},
12330
- ...top !== undefined ? { top_components: top } : {}
12331
- }), formatSessionPlaces);
12748
+ ...page || o.all ? { component_offset: ((page ?? 1) - 1) * (top ?? 5), component_limit: top ?? 5 } : top !== undefined ? { top_components: top } : {}
12749
+ };
12750
+ return out(o.all ? await allSessionPlaces(t, query) : await sessionPlaces(t, query), formatSessionPlaces);
12332
12751
  }
12333
12752
  case "layers":
12334
12753
  return out(await layers(t), formatLayers);
@@ -12347,6 +12766,100 @@ async function findingsCommand(cmd, argv, rt) {
12347
12766
  throw new UsageError([FINDINGS_USAGE]);
12348
12767
  }
12349
12768
  }
12769
+ async function allSessionPlaces(target, query) {
12770
+ let first = await sessionPlaces(target, query);
12771
+ if (first.places.some((p) => !p.components_page))
12772
+ throw new UsageError([READ_USAGE.paging]);
12773
+ if (first.session.not_computed || first.session.stale)
12774
+ return first;
12775
+ const result = { ...first, places: [], offset: 0, next_offset: null };
12776
+ const pinned = { ...query, run_set: first.session.run_set_id, branch: undefined };
12777
+ const visited = new Set;
12778
+ for (;; ) {
12779
+ const places = first.places.map((p) => ({ ...p, components: [...p.components] }));
12780
+ let response = first;
12781
+ for (;; ) {
12782
+ const next = response.places.flatMap((p) => p.components_page?.next_offset ?? []);
12783
+ if (!next.length)
12784
+ break;
12785
+ const component_offset = Math.min(...next);
12786
+ const mark = `${first.offset}:${component_offset}`;
12787
+ if (visited.has(mark))
12788
+ throw new UsageError([READ_USAGE.paging]);
12789
+ visited.add(mark);
12790
+ response = await sessionPlaces(target, { ...pinned, offset: first.offset, component_offset });
12791
+ for (const p of places) {
12792
+ const more = response.places.find((x) => x.window === p.window);
12793
+ if (!more?.components_page)
12794
+ throw new UsageError([READ_USAGE.paging]);
12795
+ const keys = new Set(p.components.map((c) => c.key));
12796
+ p.components.push(...more.components.filter((c) => !keys.has(c.key)));
12797
+ p.components_page = more.components_page;
12798
+ }
12799
+ }
12800
+ result.places.push(...places.map((p) => ({ ...p, ...p.components_page ? { components_page: { ...p.components_page, offset: 0, limit: p.components.length, next_offset: null } } : {} })));
12801
+ if (places.some((p) => p.components_page && p.components.length !== p.components_page.total))
12802
+ throw new UsageError([READ_USAGE.paging]);
12803
+ if (first.next_offset === null) {
12804
+ if (result.places.length !== result.total)
12805
+ throw new UsageError([READ_USAGE.paging]);
12806
+ return result;
12807
+ }
12808
+ const mark = `places:${first.next_offset}`;
12809
+ if (visited.has(mark))
12810
+ throw new UsageError([READ_USAGE.paging]);
12811
+ visited.add(mark);
12812
+ first = await sessionPlaces(target, { ...pinned, offset: first.next_offset, component_offset: 0 });
12813
+ }
12814
+ }
12815
+ async function allCompare(target, result, size) {
12816
+ let response = result;
12817
+ const visited = new Set;
12818
+ for (;; ) {
12819
+ if (response.windows.some((w) => w.components && !w.component_page))
12820
+ throw new UsageError([READ_USAGE.paging]);
12821
+ const offsets = response.windows.flatMap((w) => w.component_page?.page.next_offset ?? []);
12822
+ if (!offsets.length)
12823
+ break;
12824
+ const offset = Math.min(...offsets);
12825
+ if (visited.has(offset))
12826
+ throw new UsageError([READ_USAGE.paging]);
12827
+ visited.add(offset);
12828
+ response = await compareRuns(target, result.a.id, result.b.id, { component_offset: offset, component_limit: size });
12829
+ for (const w of result.windows) {
12830
+ const c = w.component_page;
12831
+ if (!c && !w.components)
12832
+ continue;
12833
+ const more = response.windows.find((x) => x.window === w.window)?.component_page;
12834
+ if (!c || !more)
12835
+ throw new UsageError([READ_USAGE.paging]);
12836
+ const keys = new Set(c.rows.map((row) => row.key));
12837
+ c.rows.push(...more.rows.filter((row) => !keys.has(row.key)));
12838
+ for (const field of ["added", "removed"]) {
12839
+ const existing = c[field] ?? [];
12840
+ const known = new Set(existing.map((row) => row.key));
12841
+ c[field] = [...existing, ...(more[field] ?? []).filter((row) => !known.has(row.key))];
12842
+ }
12843
+ for (const side of ["a", "b"]) {
12844
+ if (!c.uncompared || !more.uncompared)
12845
+ continue;
12846
+ const known = new Set(c.uncompared[side].map((row) => row.key));
12847
+ c.uncompared[side].push(...more.uncompared[side].filter((row) => !known.has(row.key)));
12848
+ }
12849
+ c.page = more.page;
12850
+ }
12851
+ }
12852
+ for (const w of result.windows) {
12853
+ const c = w.component_page;
12854
+ if (!c)
12855
+ continue;
12856
+ const count = c.rows.length + (c.added?.length ?? 0) + (c.removed?.length ?? 0) + (c.uncompared?.a.length ?? 0) + (c.uncompared?.b.length ?? 0);
12857
+ if (count !== c.page.total)
12858
+ throw new UsageError([READ_USAGE.paging]);
12859
+ c.page = { ...c.page, offset: 0, limit: count, next_offset: null };
12860
+ }
12861
+ return result;
12862
+ }
12350
12863
 
12351
12864
  // apps/agent/src/cli.ts
12352
12865
  var USAGE = `keepsnappy <command>
@@ -12354,6 +12867,8 @@ var USAGE = `keepsnappy <command>
12354
12867
  [--upload [--wait] [--fail-on-worse] [--ingest-url URL] [--commit SHA] [--branch NAME] [--base-commit SHA]]
12355
12868
  [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
12356
12869
  [--no-markers] [-- <e2e command...>]
12870
+ (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
12871
+ so with no computed verdict it checks nothing: "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing", exit 0)
12357
12872
  measure --repeat N [--arms A=<app|bundle>,B=<app|bundle>] [--arm-env 'A:KEY=VAL' ...] [--bundle-id ID] [--platform ios-sim|ios-device]
12358
12873
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
12359
12874
  [--clock-offset-ms N] [--no-markers]
@@ -12366,6 +12881,7 @@ var USAGE = `keepsnappy <command>
12366
12881
  [--driver appium --journey-module FILE] [--driver-version V] [--app-build-id ID] [--harness-version V] [--journey-version V] [--flag-set LABEL] (setup fingerprint, plan 51)
12367
12882
  [--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
12368
12883
  farm close --state <file> [--wait [--fail-on-worse]] [--bs-build browserstack.ios.json] [--settle-seconds 60] [--max-wait-seconds 300] [--out DIR]
12884
+ (--fail-on-worse as for run: with no verdict computed on the run set it checks nothing)
12369
12885
  farm run --app <eas:ID|file.ipa|file.apk|bs://ID|custom_id:ID> --journeys a,b [--repeat 5] --device "iPhone 15 Pro-17" [--platform ios|android] [--driver maestro|appium]
12370
12886
  [--tunnel-for localhost:PORT ...] [--env KEY=VALUE ...] (values may use {tunnel:PORT} {run_id} {out} {tokens_js} {run_set_id})
12371
12887
  maestro: --suite-dir DIR --execute FLOW [--tokens-path flows/keepsnappy/tokens.js] [--maestro-version V]
@@ -12408,74 +12924,80 @@ async function main2(argv) {
12408
12924
  if (cmd && LINK_COMMANDS.has(cmd))
12409
12925
  return linksCommand(cmd, rest, { print: console.log });
12410
12926
  const deps = nodeDeps();
12411
- const { values: o, positionals } = parseArgs4({
12412
- args: rest,
12413
- allowPositionals: true,
12414
- options: {
12415
- repeat: { type: "string" },
12416
- arms: { type: "string" },
12417
- "arm-env": { type: "string", multiple: true },
12418
- platform: { type: "string" },
12419
- device: { type: "string" },
12420
- out: { type: "string" },
12421
- "bundle-id": { type: "string" },
12422
- timeout: { type: "string" },
12423
- reps: { type: "string" },
12424
- boot: { type: "string" },
12425
- "ri-capture": { type: "string" },
12426
- "mem-window": { type: "string" },
12427
- "clock-offset-ms": { type: "string" },
12428
- "no-markers": { type: "boolean" },
12429
- config: { type: "string" },
12430
- upload: { type: "boolean" },
12431
- "ingest-url": { type: "string" },
12432
- commit: { type: "string" },
12433
- branch: { type: "string" },
12434
- "base-commit": { type: "string" },
12435
- wait: { type: "boolean" },
12436
- "fail-on-worse": { type: "boolean" },
12437
- scenario: { type: "string" },
12438
- "device-model": { type: "string" },
12439
- "device-os": { type: "string" },
12440
- "device-id": { type: "string" },
12441
- replay: { type: "string", multiple: true },
12442
- api: { type: "string" },
12443
- "key-env": { type: "string" },
12444
- "device-provider": { type: "string" },
12445
- journeys: { type: "string" },
12446
- "tokens-js": { type: "string" },
12447
- state: { type: "string" },
12448
- "ttl-seconds": { type: "string" },
12449
- "bs-build": { type: "string" },
12450
- "settle-seconds": { type: "string" },
12451
- "max-wait-seconds": { type: "string" },
12452
- flows: { type: "string" },
12453
- "completion-marker": { type: "boolean" },
12454
- "driver-version": { type: "string" },
12455
- "app-build-id": { type: "string" },
12456
- "harness-version": { type: "string" },
12457
- "journey-version": { type: "string" },
12458
- "flag-set": { type: "string" },
12459
- app: { type: "string" },
12460
- driver: { type: "string" },
12461
- "tunnel-for": { type: "string", multiple: true },
12462
- env: { type: "string", multiple: true },
12463
- "driver-script": { type: "string" },
12464
- "journey-module": { type: "string" },
12465
- "driver-arg": { type: "string", multiple: true },
12466
- "suite-dir": { type: "string" },
12467
- execute: { type: "string" },
12468
- "tokens-path": { type: "string" },
12469
- "maestro-version": { type: "string" },
12470
- "timeout-minutes": { type: "string" },
12471
- "kill-after-seconds": { type: "string" },
12472
- "poll-seconds": { type: "string" },
12473
- "custom-id": { type: "string" },
12474
- "run-id": { type: "string" },
12475
- "dry-run": { type: "boolean" },
12476
- "sdk-args": { type: "string" }
12927
+ const { values: o, positionals } = (() => {
12928
+ try {
12929
+ return parseArgs4({
12930
+ args: rest,
12931
+ allowPositionals: true,
12932
+ options: {
12933
+ repeat: { type: "string" },
12934
+ arms: { type: "string" },
12935
+ "arm-env": { type: "string", multiple: true },
12936
+ platform: { type: "string" },
12937
+ device: { type: "string" },
12938
+ out: { type: "string" },
12939
+ "bundle-id": { type: "string" },
12940
+ timeout: { type: "string" },
12941
+ reps: { type: "string" },
12942
+ boot: { type: "string" },
12943
+ "ri-capture": { type: "string" },
12944
+ "mem-window": { type: "string" },
12945
+ "clock-offset-ms": { type: "string" },
12946
+ "no-markers": { type: "boolean" },
12947
+ config: { type: "string" },
12948
+ upload: { type: "boolean" },
12949
+ "ingest-url": { type: "string" },
12950
+ commit: { type: "string" },
12951
+ branch: { type: "string" },
12952
+ "base-commit": { type: "string" },
12953
+ wait: { type: "boolean" },
12954
+ "fail-on-worse": { type: "boolean" },
12955
+ scenario: { type: "string" },
12956
+ "device-model": { type: "string" },
12957
+ "device-os": { type: "string" },
12958
+ "device-id": { type: "string" },
12959
+ replay: { type: "string", multiple: true },
12960
+ api: { type: "string" },
12961
+ "key-env": { type: "string" },
12962
+ "device-provider": { type: "string" },
12963
+ journeys: { type: "string" },
12964
+ "tokens-js": { type: "string" },
12965
+ state: { type: "string" },
12966
+ "ttl-seconds": { type: "string" },
12967
+ "bs-build": { type: "string" },
12968
+ "settle-seconds": { type: "string" },
12969
+ "max-wait-seconds": { type: "string" },
12970
+ flows: { type: "string" },
12971
+ "completion-marker": { type: "boolean" },
12972
+ "driver-version": { type: "string" },
12973
+ "app-build-id": { type: "string" },
12974
+ "harness-version": { type: "string" },
12975
+ "journey-version": { type: "string" },
12976
+ "flag-set": { type: "string" },
12977
+ app: { type: "string" },
12978
+ driver: { type: "string" },
12979
+ "tunnel-for": { type: "string", multiple: true },
12980
+ env: { type: "string", multiple: true },
12981
+ "driver-script": { type: "string" },
12982
+ "journey-module": { type: "string" },
12983
+ "driver-arg": { type: "string", multiple: true },
12984
+ "suite-dir": { type: "string" },
12985
+ execute: { type: "string" },
12986
+ "tokens-path": { type: "string" },
12987
+ "maestro-version": { type: "string" },
12988
+ "timeout-minutes": { type: "string" },
12989
+ "kill-after-seconds": { type: "string" },
12990
+ "poll-seconds": { type: "string" },
12991
+ "custom-id": { type: "string" },
12992
+ "run-id": { type: "string" },
12993
+ "dry-run": { type: "boolean" },
12994
+ "sdk-args": { type: "string" }
12995
+ }
12996
+ });
12997
+ } catch (e) {
12998
+ throw new UsageError([e.message, "keepsnappy --help lists the commands and their flags"]);
12477
12999
  }
12478
- });
13000
+ })();
12479
13001
  if (cmd === "doctor") {
12480
13002
  console.log(formatChecks(await doctor(deps.spawner, process.version)));
12481
13003
  return 0;
@@ -12495,12 +13017,12 @@ async function main2(argv) {
12495
13017
  if (cmd === "measure") {
12496
13018
  const repeat = Number(o.repeat);
12497
13019
  if (!Number.isInteger(repeat) || repeat < 1)
12498
- throw new Error("--repeat N (>= 1) is required");
13020
+ throw new ConfigError(["--repeat N (>= 1) is required"]);
12499
13021
  const platform = o.platform ?? "ios-sim";
12500
13022
  if (platform !== "ios-sim" && platform !== "ios-device")
12501
- throw new Error(`unknown --platform ${platform}`);
13023
+ throw new ConfigError([`unknown --platform ${platform}`]);
12502
13024
  if (platform === "ios-device" && !o.device)
12503
- throw new Error("--device <udid> is required for ios-device");
13025
+ throw new ConfigError(["--device <udid> is required for ios-device"]);
12504
13026
  const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
12505
13027
  const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
12506
13028
  const udid = o.device ?? "booted";
@@ -12534,7 +13056,7 @@ async function main2(argv) {
12534
13056
  if (cmd === "extract") {
12535
13057
  const dir = positionals[0];
12536
13058
  if (!dir)
12537
- throw new Error("extract needs <out-dir>");
13059
+ throw new ConfigError(["extract needs <out-dir>"]);
12538
13060
  const ri = o["ri-capture"] ? new FileRiSource(deps, resolve8(o["ri-capture"])) : undefined;
12539
13061
  const rows = await extract(deps, resolve8(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
12540
13062
  console.log(`${rows.length} rows -> ${resolve8(dir, "metrics.csv")}`);
@@ -12543,7 +13065,7 @@ async function main2(argv) {
12543
13065
  if (cmd === "aa") {
12544
13066
  const dir = positionals[0];
12545
13067
  if (!dir)
12546
- throw new Error("aa needs <out-dir>");
13068
+ throw new ConfigError(["aa needs <out-dir>"]);
12547
13069
  const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
12548
13070
  console.log((await aa(deps, resolve8(dir), { config })).table);
12549
13071
  return 0;