xcodebuild-axi 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,88 @@
1
1
  import { AxiError } from "../errors.js";
2
- import { resolve } from "node:path";
3
- import { describeDevice, readBuildResults, readTestSummary, toDiagnostics, } from "../xcresult.js";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { exportDir, mergedBundlePath } from "../xcodebuild.js";
5
+ import { describeDevice, exportBundle, EXPORT_KINDS, mergeBundles, readComparison, readActivities, readBuildResults, readBundleMetadata, readContentAvailability, readInsights, readLog, readMetrics, readTests, readTestSummary, toDiagnostics, } from "../xcresult.js";
4
6
  import { diagnosticsBlock, failureRows } from "../report.js";
5
- import { duration, renderFields, renderHelp, renderList, renderOutput, tildePath, } from "../toon.js";
6
- import { getIntFlag, hasFlag, positionals, rejectUnknownFlags, } from "../args.js";
7
+ import { byteSize, duration, renderFields, renderHelp, renderList, renderOutput, tildePath, truncate, } from "../toon.js";
8
+ import { getFlag, getIntFlag, hasFlag, positionals, rejectUnknownFlags, } from "../args.js";
7
9
  export const RESULT_HELP = `usage: xcodebuild-axi result <path.xcresult> [flags]
8
10
  Re-reads a result bundle that a previous run wrote, without rebuilding.
9
- flags[4]:
11
+ flags[17]:
10
12
  --failures failures and errors only
11
13
  --warnings include the full warning list
14
+ --tests every test the run recorded, as the tree Xcode groups them into
15
+ --insights Xcode's own diagnosis: what failed together, and what was slow
16
+ --activities what one test did, step by step; needs --test
17
+ --metrics what the performance tests measured; --test narrows it
18
+ --log <type> the stored build, action, or console log, as timed sections
19
+ --available what this bundle holds at all: coverage, logs, test results
20
+ --metadata when the bundle was written, and in what format
21
+ --export <what> write part of the bundle out: attachments, diagnostics,
22
+ metrics, or evaluations
23
+ --against <path> compare this run to a baseline bundle: what broke, what
24
+ got fixed, which tests came and went
25
+ --merge combine two or more bundles into one, for a sharded run
26
+ --to <path> where --export or --merge writes (default: under
27
+ ~/Library/Caches)
28
+ --test <id> the test --activities, --metrics or --export is about
29
+ --filter <glob> with --export attachments: filenames to keep, e.g. '*.png'
12
30
  --max <n> rows to list before summarizing the rest (default: 20)
13
31
  --full untruncated messages
32
+ note:
33
+ Everything here reads the bundle a run already wrote, so none of it rebuilds
34
+ anything. --available is the cheapest first question about a bundle from
35
+ somewhere else: it says whether there is coverage or a log to ask for,
36
+ instead of letting you find out by failing to read one.
37
+
38
+ --export writes files rather than printing them, so it reports what landed
39
+ and where. --failures narrows attachments and evaluations to what a failing
40
+ test produced, which is usually all anyone wants out of a green run's
41
+ hundreds of screenshots.
42
+
43
+ --against answers 'is this worse than before' in one call: a failure the
44
+ baseline did not have is what a CI check is looking for, and it is reported
45
+ ahead of everything else.
14
46
  examples:
15
47
  xcodebuild-axi result ~/Library/Caches/xcodebuild-axi/MyApps-1a2b3c4d/MyApp-iPhone-17-Pro-test.xcresult
16
48
  xcodebuild-axi result build/MyApp.xcresult --failures --full
49
+ xcodebuild-axi result build/MyApp.xcresult --log build --max 10
50
+ xcodebuild-axi result build/MyApp.xcresult --activities --test MyAppTests/CheckoutTests/testTotal
51
+ xcodebuild-axi result build/MyApp.xcresult --export attachments --failures
52
+ xcodebuild-axi result build/MyApp.xcresult --against build/baseline.xcresult
53
+ xcodebuild-axi result shard1.xcresult shard2.xcresult --merge
17
54
  `;
18
55
  export const RESULT_FLAGS = [
19
56
  "--failures",
20
57
  "--warnings",
58
+ "--tests",
59
+ "--insights",
60
+ "--activities",
61
+ "--metrics",
62
+ "--log",
63
+ "--available",
64
+ "--metadata",
65
+ "--export",
66
+ "--against",
67
+ "--merge",
68
+ "--to",
69
+ "--test",
70
+ "--filter",
21
71
  "--max",
22
72
  "--full",
23
73
  ];
74
+ const VALUE_FLAGS = [
75
+ "--max",
76
+ "--log",
77
+ "--test",
78
+ "--export",
79
+ "--against",
80
+ "--to",
81
+ "--filter",
82
+ ];
24
83
  export async function resultCommand(args) {
25
- rejectUnknownFlags(args, "result", RESULT_FLAGS, ["--max"]);
26
- const [rawPath] = positionals(args, ["--max"]);
84
+ rejectUnknownFlags(args, "result", RESULT_FLAGS, VALUE_FLAGS);
85
+ const [rawPath] = positionals(args, VALUE_FLAGS);
27
86
  if (rawPath === undefined) {
28
87
  throw new AxiError("result needs a path to an .xcresult bundle", "VALIDATION_ERROR", [
29
88
  "xcodebuild-axi result <path.xcresult>",
@@ -34,6 +93,9 @@ export async function resultCommand(args) {
34
93
  const max = getIntFlag(args, "--max") ?? 20;
35
94
  const full = hasFlag(args, "--full");
36
95
  const failuresOnly = hasFlag(args, "--failures");
96
+ const mode = soleMode(args);
97
+ if (mode)
98
+ return readMode(mode, path, args, max);
37
99
  const [summary, build] = await Promise.all([
38
100
  readTestSummary(path).catch(() => undefined),
39
101
  readBuildResults(path).catch(() => undefined),
@@ -103,6 +165,591 @@ export async function resultCommand(args) {
103
165
  blocks.push(renderHelp(hints));
104
166
  return renderOutput(blocks);
105
167
  }
168
+ /** The one read this invocation is for, if it is not the default report. */
169
+ const MODES = [
170
+ "--tests",
171
+ "--insights",
172
+ "--activities",
173
+ "--metrics",
174
+ "--log",
175
+ "--available",
176
+ "--metadata",
177
+ "--export",
178
+ "--against",
179
+ "--merge",
180
+ ];
181
+ /**
182
+ * These answer different questions from each other and from the default
183
+ * report, so two at once would print two reports and call it one answer.
184
+ */
185
+ function soleMode(args) {
186
+ const asked = MODES.filter((mode) => args.includes(mode));
187
+ if (asked.length > 1) {
188
+ throw new AxiError(`${asked.join(" and ")} ask different questions — pass one`, "VALIDATION_ERROR", [`each is its own read of the bundle: ${MODES.join(", ")}`]);
189
+ }
190
+ return asked[0];
191
+ }
192
+ async function readMode(mode, path, args, max) {
193
+ const bundle = renderFields({ bundle: tildePath(path) });
194
+ switch (mode) {
195
+ case "--tests":
196
+ return reportTests(await readTests(path), max, bundle);
197
+ case "--insights":
198
+ return reportInsights(await readInsights(path), max, bundle);
199
+ case "--activities":
200
+ return reportActivities(await readActivities(path, requireTest(args, "--activities")), max, bundle);
201
+ case "--metrics":
202
+ return reportMetrics(await readMetrics(path, getFlag(args, "--test")), max, bundle);
203
+ case "--log":
204
+ return reportLog(await readLog(path, logType(args)), logType(args), max, bundle);
205
+ case "--available":
206
+ return reportAvailability(await readContentAvailability(path), bundle);
207
+ case "--metadata":
208
+ return reportMetadata(await readBundleMetadata(path), bundle);
209
+ case "--export":
210
+ return runExport(path, args, max, bundle);
211
+ case "--against":
212
+ return reportComparison(await readComparison(path, baselineOf(args)), args, max, bundle);
213
+ case "--merge":
214
+ return runMerge(args, max);
215
+ }
216
+ }
217
+ function requireTest(args, mode) {
218
+ const test = getFlag(args, "--test");
219
+ if (test === undefined) {
220
+ throw new AxiError(`${mode} is about one test`, "VALIDATION_ERROR", [
221
+ `xcodebuild-axi result <path.xcresult> ${mode} --test MyAppTests/CheckoutTests/testTotal`,
222
+ "Run `xcodebuild-axi result <path.xcresult> --tests` to see the identifiers",
223
+ ]);
224
+ }
225
+ return test;
226
+ }
227
+ const LOG_TYPES = ["build", "action", "console"];
228
+ function logType(args) {
229
+ const type = getFlag(args, "--log") ?? "build";
230
+ if (!LOG_TYPES.includes(type)) {
231
+ throw new AxiError(`Unknown log type '${type}'`, "VALIDATION_ERROR", [
232
+ `valid types: ${LOG_TYPES.join(", ")}`,
233
+ ]);
234
+ }
235
+ return type;
236
+ }
237
+ /**
238
+ * `--export`, which writes files instead of printing them.
239
+ *
240
+ * The shape of the answer is therefore what landed and where, rather than the
241
+ * content: a screenshot is not something to render into a terminal, but the
242
+ * path to one is exactly what the next tool call needs.
243
+ */
244
+ async function runExport(path, args, max, bundle) {
245
+ const kind = exportKind(args);
246
+ const requested = getFlag(args, "--to");
247
+ const outputPath = requested
248
+ ? resolve(expandTilde(requested))
249
+ : exportDir(path, kind);
250
+ const testId = getFlag(args, "--test");
251
+ const filter = getFlag(args, "--filter");
252
+ const onlyFailures = hasFlag(args, "--failures");
253
+ // Refused rather than dropped, because each of these changes what comes out.
254
+ // Passing --filter to a diagnostics export and getting everything back is a
255
+ // wrong answer that looks like a right one.
256
+ if (filter !== undefined && kind !== "attachments") {
257
+ throw new AxiError(`--filter narrows attachments by filename, and ${kind} has none`, "VALIDATION_ERROR", ["Drop --filter, or export attachments"]);
258
+ }
259
+ if (testId !== undefined && kind === "diagnostics") {
260
+ throw new AxiError("A diagnostics report covers the whole run, not one test", "VALIDATION_ERROR", ["Drop --test, or export attachments, metrics or evaluations"]);
261
+ }
262
+ if (onlyFailures && (kind === "diagnostics" || kind === "metrics")) {
263
+ throw new AxiError(`--failures narrows an export to what a failing test produced, and ${kind} is not per-test`, "VALIDATION_ERROR", ["Drop --failures, or export attachments or evaluations"]);
264
+ }
265
+ await exportBundle({ path, kind, outputPath, testId, filter, onlyFailures });
266
+ const files = walkFiles(outputPath).filter((file) => basename(file.path) !== MANIFEST);
267
+ const to = renderFields({ to: tildePath(outputPath) });
268
+ if (files.length === 0) {
269
+ return renderOutput([
270
+ renderFields({
271
+ export: kind,
272
+ files: `none — this bundle holds no ${kind}${describeNarrowing(testId, filter, onlyFailures)}`,
273
+ }),
274
+ bundle,
275
+ renderHelp([
276
+ "Run `xcodebuild-axi result <path> --available` to see what this bundle holds",
277
+ ...(kind === "attachments"
278
+ ? [
279
+ "Xcode keeps attachments only when the test asks it to, or when the test failed",
280
+ ]
281
+ : []),
282
+ ]),
283
+ ]);
284
+ }
285
+ // The manifest is what ties an exported filename back to the test that made
286
+ // it. When it is missing or says nothing -- an older bundle, or a directory
287
+ // reused for a second export -- the files that are actually there are still
288
+ // a true answer, and a truer one than an empty table.
289
+ const named = kind === "diagnostics" ? [] : manifestRows(outputPath, kind);
290
+ const rows = kind === "diagnostics"
291
+ ? diagnosticRows(outputPath)
292
+ : named.length > 0
293
+ ? named
294
+ : files.map((file) => ({
295
+ file: relative(outputPath, file.path),
296
+ size: byteSize(file.bytes),
297
+ }));
298
+ const shown = rows.slice(0, max);
299
+ return renderOutput([
300
+ renderFields({
301
+ export: kind,
302
+ files: files.length,
303
+ size: byteSize(files.reduce((sum, file) => sum + file.bytes, 0)),
304
+ }),
305
+ to,
306
+ renderList(rows.length > shown.length
307
+ ? `exported (${shown.length} of ${rows.length})`
308
+ : "exported", shown),
309
+ bundle,
310
+ ]);
311
+ }
312
+ /**
313
+ * `--against`, which is the question CI actually asks: not "did this run
314
+ * fail" but "did it fail in a way the last one did not".
315
+ *
316
+ * A failure the baseline did not have is reported ahead of everything else,
317
+ * because it is the only part of a comparison that stops a merge.
318
+ */
319
+ function reportComparison(differential, args, max, bundle) {
320
+ const baseline = renderFields({ baseline: tildePath(baselineOf(args)) });
321
+ if (differential === null) {
322
+ return renderOutput([
323
+ renderFields({
324
+ compare: "nothing comparable — these two bundles hold different things",
325
+ }),
326
+ baseline,
327
+ bundle,
328
+ renderHelp([
329
+ "Run `xcodebuild-axi result <path> --available` on each to see what they hold",
330
+ "A build bundle and a test bundle have no common ground to compare",
331
+ ]),
332
+ ]);
333
+ }
334
+ const summary = differential.summary ?? {};
335
+ const tests = summary.testsExecuted ?? {};
336
+ const blocks = [
337
+ renderFields({
338
+ tests: `${tests.itemsInBaseline ?? 0} → ${tests.itemsInCurrent ?? 0}`,
339
+ added: tests.added ?? 0,
340
+ removed: tests.removed ?? 0,
341
+ failures: deltaField(summary.testFailures),
342
+ warnings: deltaField(summary.buildWarnings),
343
+ analyzer: deltaField(summary.analyzerIssues),
344
+ }),
345
+ ];
346
+ const introduced = differential.testFailures?.introduced ?? [];
347
+ const resolved = differential.testFailures?.resolved ?? [];
348
+ if (introduced.length > 0) {
349
+ blocks.push(cappedList("newly_failing", failureDeltaRows(introduced), max));
350
+ }
351
+ if (resolved.length > 0) {
352
+ blocks.push(cappedList("now_passing", failureDeltaRows(resolved), max));
353
+ }
354
+ const added = differential.testsExecuted?.added ?? [];
355
+ const removed = differential.testsExecuted?.removed ?? [];
356
+ if (added.length > 0) {
357
+ blocks.push(cappedList("added_tests", testRefRows(added), max));
358
+ }
359
+ if (removed.length > 0) {
360
+ blocks.push(cappedList("removed_tests", testRefRows(removed), max));
361
+ }
362
+ const newWarnings = [
363
+ ...(differential.buildWarnings?.introduced ?? []),
364
+ ...(differential.analyzerIssues?.introduced ?? []),
365
+ ];
366
+ if (newWarnings.length > 0) {
367
+ blocks.push(cappedList("new_warnings", issueRows(newWarnings), max));
368
+ }
369
+ if (blocks.length === 1) {
370
+ blocks.push(renderFields({ difference: "none — this run matches the baseline" }));
371
+ }
372
+ return renderOutput([...blocks, baseline, bundle]);
373
+ }
374
+ function baselineOf(args) {
375
+ const against = getFlag(args, "--against");
376
+ if (against === undefined) {
377
+ throw new AxiError("--against needs the baseline bundle to compare with", "VALIDATION_ERROR", ["xcodebuild-axi result <path.xcresult> --against <baseline.xcresult>"]);
378
+ }
379
+ return resolve(expandTilde(against));
380
+ }
381
+ /** "0 → 2 (+2 -0)", which says both the level and the direction. */
382
+ export function deltaField(delta) {
383
+ const before = delta?.itemsInBaseline ?? 0;
384
+ const after = delta?.itemsInCurrent ?? 0;
385
+ return `${before} → ${after} (+${delta?.introduced ?? 0} -${delta?.resolved ?? 0})`;
386
+ }
387
+ export function failureDeltaRows(deltas) {
388
+ return deltas.map((delta) => ({
389
+ test: delta.associatedTest?.testIdentifier ?? delta.associatedTest?.name ?? "",
390
+ message: truncate(delta.failureMessage ?? "", 300).text,
391
+ }));
392
+ }
393
+ function testRefRows(refs) {
394
+ return refs.map((ref) => ({ test: ref.testIdentifier ?? ref.name ?? "" }));
395
+ }
396
+ function issueRows(issues) {
397
+ return issues.map((issue) => ({
398
+ target: issue.producingTarget ?? "",
399
+ message: truncate(issue.message ?? "", 300).text,
400
+ }));
401
+ }
402
+ function cappedList(label, rows, max) {
403
+ const shown = rows.slice(0, max);
404
+ return renderList(rows.length > shown.length
405
+ ? `${label} (${shown.length} of ${rows.length})`
406
+ : label, shown);
407
+ }
408
+ /**
409
+ * `--merge`, which is how a sharded test run gets one verdict.
410
+ *
411
+ * The merged bundle is read back afterwards rather than described from the
412
+ * inputs: the point of merging is the combined counts, and reporting them from
413
+ * the thing that was actually written is the only way they are true.
414
+ */
415
+ async function runMerge(args, max) {
416
+ const paths = positionals(args, VALUE_FLAGS).map((path) => resolve(expandTilde(path)));
417
+ if (paths.length < 2) {
418
+ throw new AxiError(`--merge combines two or more bundles, and ${paths.length} was given`, "VALIDATION_ERROR", [
419
+ "xcodebuild-axi result shard1.xcresult shard2.xcresult --merge",
420
+ "Each `test` run prints the bundle path it wrote",
421
+ ]);
422
+ }
423
+ const requested = getFlag(args, "--to");
424
+ const outputPath = requested
425
+ ? resolve(expandTilde(requested))
426
+ : mergedBundlePath(paths);
427
+ // A bundle the caller named is theirs, and xcresulttool will write straight
428
+ // over it. The one under our own cache directory is ours to clear, which is
429
+ // the same split `runBuild` makes about the bundle it writes.
430
+ if (requested && existsSync(outputPath)) {
431
+ throw new AxiError(`Something is already at ${tildePath(outputPath)}`, "VALIDATION_ERROR", [
432
+ "Pass a path that does not exist yet, or drop --to to write under ~/Library/Caches",
433
+ ]);
434
+ }
435
+ if (!requested)
436
+ rmSync(outputPath, { recursive: true, force: true });
437
+ mkdirSync(dirname(outputPath), { recursive: true });
438
+ await mergeBundles(paths, outputPath);
439
+ const merged = await readTestSummary(outputPath).catch(() => undefined);
440
+ return renderOutput([
441
+ renderFields({
442
+ merged: paths.length,
443
+ ...(merged && (merged.totalTestCount ?? 0) > 0
444
+ ? {
445
+ tests: `${merged.passedTests ?? 0} passed / ${merged.failedTests ?? 0} failed / ${merged.skippedTests ?? 0} skipped`,
446
+ result: merged.result?.toLowerCase() ?? "unknown",
447
+ }
448
+ : {}),
449
+ to: tildePath(outputPath),
450
+ }),
451
+ cappedList("from", paths.map((path) => ({ bundle: tildePath(path) })), max),
452
+ renderHelp([
453
+ `Run \`xcodebuild-axi result ${tildePath(outputPath)}\` to report on the merged run`,
454
+ ]),
455
+ ]);
456
+ }
457
+ const MANIFEST = "manifest.json";
458
+ function exportKind(args) {
459
+ const what = getFlag(args, "--export");
460
+ if (what === undefined || !EXPORT_KINDS.includes(what)) {
461
+ throw new AxiError(what === undefined
462
+ ? "--export needs to know what to write out"
463
+ : `Nothing named '${what}' can be exported from a result bundle`, "VALIDATION_ERROR", [`what can be exported: ${EXPORT_KINDS.join(", ")}`]);
464
+ }
465
+ return what;
466
+ }
467
+ /** Say which narrowing produced an empty export, so it can be lifted. */
468
+ function describeNarrowing(testId, filter, onlyFailures) {
469
+ const applied = [
470
+ testId ? `for '${testId}'` : undefined,
471
+ filter ? `matching '${filter}'` : undefined,
472
+ onlyFailures ? "from a failing test" : undefined,
473
+ ].filter((part) => part !== undefined);
474
+ return applied.length > 0 ? ` ${applied.join(" ")}` : "";
475
+ }
476
+ /**
477
+ * The manifest `xcresulttool` writes beside the files it exported, which is
478
+ * the only thing that ties an exported filename back to the test that made it.
479
+ */
480
+ export function manifestRows(outputPath, kind) {
481
+ const manifestPath = join(outputPath, MANIFEST);
482
+ if (!existsSync(manifestPath))
483
+ return [];
484
+ let entries;
485
+ try {
486
+ entries = JSON.parse(readFileSync(manifestPath, "utf-8"));
487
+ }
488
+ catch {
489
+ return [];
490
+ }
491
+ const rows = [];
492
+ for (const entry of entries) {
493
+ const test = entry.testIdentifier ?? "";
494
+ for (const file of entry.metricsFiles ?? [])
495
+ rows.push({ test, file });
496
+ for (const attachment of entry.attachments ?? []) {
497
+ rows.push({
498
+ test,
499
+ file: attachment.exportedFileName ?? "",
500
+ name: attachment.suggestedHumanReadableName ?? "",
501
+ ...(kind === "attachments"
502
+ ? { failure: attachment.isAssociatedWithFailure === true }
503
+ : {}),
504
+ });
505
+ }
506
+ }
507
+ return rows;
508
+ }
509
+ /**
510
+ * A diagnostics export has no manifest — it is a directory per device per
511
+ * action, each holding dozens of logs. One row per top-level directory says
512
+ * what was collected without printing a file list nobody reads.
513
+ */
514
+ function diagnosticRows(outputPath) {
515
+ return readdirSync(outputPath, { withFileTypes: true })
516
+ .filter((entry) => entry.name !== MANIFEST)
517
+ .map((entry) => {
518
+ const full = join(outputPath, entry.name);
519
+ const files = entry.isDirectory()
520
+ ? walkFiles(full)
521
+ : [{ path: full, bytes: sizeOf(full) }];
522
+ return {
523
+ report: entry.name,
524
+ files: files.length,
525
+ size: byteSize(files.reduce((sum, file) => sum + file.bytes, 0)),
526
+ };
527
+ })
528
+ .sort((a, b) => String(a.report).localeCompare(String(b.report)));
529
+ }
530
+ function walkFiles(dir) {
531
+ if (!existsSync(dir))
532
+ return [];
533
+ const found = [];
534
+ const walk = (current) => {
535
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
536
+ const full = join(current, entry.name);
537
+ if (entry.isDirectory())
538
+ walk(full);
539
+ else
540
+ found.push({ path: full, bytes: sizeOf(full) });
541
+ }
542
+ };
543
+ walk(dir);
544
+ return found;
545
+ }
546
+ function sizeOf(path) {
547
+ try {
548
+ return statSync(path).size;
549
+ }
550
+ catch {
551
+ return 0;
552
+ }
553
+ }
554
+ /**
555
+ * The test tree, flattened to one row per test case.
556
+ *
557
+ * Xcode nests plan -> target -> suite -> case, and printing that as a tree
558
+ * costs more tokens than it explains. The identifier already carries the
559
+ * hierarchy, so the rows carry the identifier and the verdict.
560
+ */
561
+ export function flattenTests(nodes) {
562
+ const rows = [];
563
+ const walk = (node) => {
564
+ if (node.nodeType === "Test Case") {
565
+ rows.push({
566
+ test: node.nodeIdentifier ?? node.name ?? "",
567
+ result: (node.result ?? "").toLowerCase(),
568
+ duration: node.duration ?? "",
569
+ });
570
+ }
571
+ for (const child of node.children ?? [])
572
+ walk(child);
573
+ };
574
+ for (const node of nodes ?? [])
575
+ walk(node);
576
+ return rows;
577
+ }
578
+ function reportTests(tree, max, bundle) {
579
+ const rows = flattenTests(tree.testNodes);
580
+ if (rows.length === 0) {
581
+ return renderOutput([
582
+ renderFields({ tests: "none — this bundle recorded no tests" }),
583
+ bundle,
584
+ ]);
585
+ }
586
+ // A failing test is the reason anyone asks, so it is never the row that
587
+ // gets cut when the list is capped.
588
+ const ordered = [
589
+ ...rows.filter((row) => row.result !== "passed"),
590
+ ...rows.filter((row) => row.result === "passed"),
591
+ ];
592
+ const shown = ordered.slice(0, max);
593
+ const device = tree.devices?.[0];
594
+ return renderOutput([
595
+ renderFields({
596
+ tests: rows.length,
597
+ failed: rows.filter((row) => row.result === "failed").length,
598
+ ...(device ? { destination: describeDevice(device) } : {}),
599
+ }),
600
+ renderList(rows.length > shown.length
601
+ ? `test_list (${shown.length} of ${rows.length})`
602
+ : "test_list", shown),
603
+ bundle,
604
+ renderHelp(rows.length > shown.length
605
+ ? [`Run the same command with \`--max ${rows.length}\` for all of them`]
606
+ : []),
607
+ ]);
608
+ }
609
+ function reportInsights(insights, max, bundle) {
610
+ const groups = [
611
+ ["common_failures", insights.commonFailureInsights],
612
+ ["failure_distribution", insights.failureDistributionInsights],
613
+ ["longest_runs", insights.longestTestRunsInsights],
614
+ ];
615
+ const blocks = groups.flatMap(([name, items]) => items && items.length > 0
616
+ ? [
617
+ renderList(name, items.slice(0, max).map((item) => ({
618
+ insight: item.text ?? item.testName ?? item.category ?? "",
619
+ ...(item.impact ? { impact: item.impact } : {}),
620
+ ...(item.totalDuration !== undefined
621
+ ? { duration: duration(item.totalDuration) }
622
+ : {}),
623
+ }))),
624
+ ]
625
+ : []);
626
+ if (blocks.length === 0) {
627
+ // Xcode returning three empty lists is an answer: it found nothing worth
628
+ // saying, which is not the same as this command failing to ask.
629
+ return renderOutput([
630
+ renderFields({ insights: "none — Xcode found nothing to report" }),
631
+ bundle,
632
+ ]);
633
+ }
634
+ return renderOutput([...blocks, bundle]);
635
+ }
636
+ function reportActivities(activities, max, bundle) {
637
+ const rows = [];
638
+ const walk = (node, depth) => {
639
+ if (node.title)
640
+ rows.push({ step: node.title, depth });
641
+ for (const child of node.childActivities ?? [])
642
+ walk(child, depth + 1);
643
+ };
644
+ for (const run of activities.testRuns ?? []) {
645
+ for (const activity of run.activities ?? [])
646
+ walk(activity, 0);
647
+ }
648
+ const shown = rows.slice(0, max);
649
+ return renderOutput([
650
+ renderFields({
651
+ test: activities.testName ?? activities.testIdentifier ?? "",
652
+ steps: rows.length,
653
+ }),
654
+ rows.length > 0
655
+ ? renderList(rows.length > shown.length
656
+ ? `activities (${shown.length} of ${rows.length})`
657
+ : "activities", shown)
658
+ : renderFields({ activities: "none recorded for this test" }),
659
+ bundle,
660
+ ]);
661
+ }
662
+ function reportMetrics(metrics, max, bundle) {
663
+ const rows = metrics.flatMap((metric) => (metric.measurements ?? []).map((measurement) => ({
664
+ test: metric.testName ?? metric.testIdentifier ?? "",
665
+ metric: measurement.displayName ?? "",
666
+ value: measurement.average ?? "",
667
+ unit: measurement.unit ?? "",
668
+ baseline: measurement.baselineAverage ?? "",
669
+ })));
670
+ if (rows.length === 0) {
671
+ return renderOutput([
672
+ renderFields({
673
+ metrics: "none — no performance measurements in this bundle",
674
+ }),
675
+ bundle,
676
+ renderHelp([
677
+ "Performance metrics come from XCTMetric tests; a plain test run records none",
678
+ ]),
679
+ ]);
680
+ }
681
+ const shown = rows.slice(0, max);
682
+ return renderOutput([
683
+ renderList(rows.length > shown.length
684
+ ? `metrics (${shown.length} of ${rows.length})`
685
+ : "metrics", shown),
686
+ bundle,
687
+ ]);
688
+ }
689
+ /**
690
+ * The stored log, as the timed tree it is rather than as text.
691
+ *
692
+ * The transcript on disk is the same content as a flat stream; what the bundle
693
+ * adds is which section took how long, which is the question worth a
694
+ * subprocess -- the slowest step of a build, without rebuilding it.
695
+ */
696
+ function reportLog(log, type, max, bundle) {
697
+ // Sorted on the seconds rather than on the formatted string: `1m16s`
698
+ // parses as 1, so a minute-long section would rank below a five-second one.
699
+ const sections = (log.subsections ?? [])
700
+ .map((section) => ({
701
+ section: section.title ?? "",
702
+ seconds: section.duration ?? 0,
703
+ result: (section.result ?? "").toLowerCase(),
704
+ }))
705
+ .sort((a, b) => b.seconds - a.seconds);
706
+ const shown = sections.slice(0, max).map(({ section, seconds, result }) => ({
707
+ section,
708
+ duration: duration(seconds),
709
+ result,
710
+ }));
711
+ return renderOutput([
712
+ renderFields({
713
+ log: type,
714
+ result: (log.result ?? "unknown").toLowerCase(),
715
+ duration: duration(log.duration ?? 0),
716
+ sections: sections.length,
717
+ }),
718
+ sections.length > 0
719
+ ? renderList(sections.length > shown.length
720
+ ? `slowest (${shown.length} of ${sections.length})`
721
+ : "sections", shown)
722
+ : renderFields({ sections: "none recorded" }),
723
+ bundle,
724
+ ]);
725
+ }
726
+ function reportAvailability(available, bundle) {
727
+ return renderOutput([
728
+ renderFields({
729
+ test_results: available.hasTestResults === true,
730
+ coverage: available.hasCoverage === true,
731
+ diagnostics: available.hasDiagnostics === true,
732
+ logs: available.logs ?? [],
733
+ }),
734
+ bundle,
735
+ ]);
736
+ }
737
+ function reportMetadata(metadata, bundle) {
738
+ return renderOutput([
739
+ renderFields({
740
+ created: metadata.dateCreated ?? "unknown",
741
+ format: `${metadata.version?.major ?? "?"}.${metadata.version?.minor ?? "?"}`,
742
+ ...(metadata.storage?.backend
743
+ ? { storage: metadata.storage.backend }
744
+ : {}),
745
+ ...(metadata.storage?.compression
746
+ ? { compression: metadata.storage.compression }
747
+ : {}),
748
+ external_locations: (metadata.externalLocations ?? []).length,
749
+ }),
750
+ bundle,
751
+ ]);
752
+ }
106
753
  /**
107
754
  * The header a non-test bundle reports. A build bundle carries every field the
108
755
  * test-shaped query lacks: `actionTitle` names the action xcodebuild ran,