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.
- package/CHANGELOG.md +152 -1
- package/README.md +25 -57
- package/dist/src/archive.d.ts +35 -6
- package/dist/src/archive.js +63 -21
- package/dist/src/archive.js.map +1 -1
- package/dist/src/commands/coverage.d.ts +13 -2
- package/dist/src/commands/coverage.js +314 -7
- package/dist/src/commands/coverage.js.map +1 -1
- package/dist/src/commands/export.d.ts +4 -4
- package/dist/src/commands/export.js +194 -11
- package/dist/src/commands/export.js.map +1 -1
- package/dist/src/commands/info.d.ts +2 -2
- package/dist/src/commands/info.js +56 -3
- package/dist/src/commands/info.js.map +1 -1
- package/dist/src/commands/result.d.ts +23 -3
- package/dist/src/commands/result.js +654 -7
- package/dist/src/commands/result.js.map +1 -1
- package/dist/src/surface.d.ts +7 -1
- package/dist/src/surface.js +41 -164
- package/dist/src/surface.js.map +1 -1
- package/dist/src/toon.d.ts +2 -0
- package/dist/src/toon.js +15 -0
- package/dist/src/toon.js.map +1 -1
- package/dist/src/xccov.d.ts +94 -0
- package/dist/src/xccov.js +178 -2
- package/dist/src/xccov.js.map +1 -1
- package/dist/src/xcodebuild.d.ts +18 -0
- package/dist/src/xcodebuild.js +38 -1
- package/dist/src/xcodebuild.js.map +1 -1
- package/dist/src/xcresult.d.ts +216 -0
- package/dist/src/xcresult.js +188 -4
- package/dist/src/xcresult.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,29 +1,88 @@
|
|
|
1
1
|
import { AxiError } from "../errors.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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[
|
|
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,
|
|
26
|
-
const [rawPath] = positionals(args,
|
|
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,
|