Skip to content

Commit 31ea2c0

Browse files
committed
Materialize build logs for local inspection
1 parent 388a3c4 commit 31ea2c0

5 files changed

Lines changed: 196 additions & 77 deletions

File tree

src/tools/results.ts

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@ import {
77
type BuildSelector,
88
} from '../utils/build-locator.js';
99
import { collectBuildRunArtifacts } from '../utils/build-artifacts.js';
10-
import {
11-
extractTextFromArtifact,
12-
summarizeLogTexts,
13-
} from '../utils/log-analysis.js';
10+
import { summarizeLogTexts } from '../utils/log-analysis.js';
11+
import { storeLogArtifacts } from '../utils/log-storage.js';
1412
import { errorResponse, jsonResponse } from '../utils/tool-response.js';
1513

1614
interface BuildLookupInput {
@@ -62,18 +60,25 @@ export function registerResultTools(
6260
try {
6361
const buildRun = await resolveBuildLocator(client, input);
6462
const groupedArtifacts = await collectBuildRunArtifacts(client, buildRun.id);
65-
const maxCharacters = input.maxCharacters ?? 8000;
66-
const parsedLogTexts = await downloadLogTexts(
63+
const maxCharacters = Math.min(input.maxCharacters ?? 2000, 4000);
64+
const storedLogs = await storeLogArtifacts(
6765
client,
66+
buildRun.id,
6867
groupedArtifacts.logs,
6968
);
70-
const logSummary = summarizeLogTexts(parsedLogTexts, maxCharacters);
69+
const logSummary = summarizeLogTexts(
70+
storedLogs.parsedLogTexts,
71+
maxCharacters,
72+
);
7173

7274
return jsonResponse({
7375
buildRun: formatBuildRun(buildRun),
7476
issueCounts: buildRun.attributes.issueCounts ?? defaultIssueCounts(),
7577
artifacts: groupedArtifacts.logs.map(formatArtifact),
78+
savedLogsDirectory: storedLogs.directoryPath,
79+
savedLogs: storedLogs.savedLogFiles,
7680
summary: logSummary.summary,
81+
failedTests: logSummary.failedTests,
7782
highlights: logSummary.highlights,
7883
excerpt: logSummary.excerpt,
7984
});
@@ -123,34 +128,3 @@ function formatArtifact(artifact: CiArtifact) {
123128
downloadUrl: artifact.attributes.downloadUrl,
124129
};
125130
}
126-
127-
async function downloadLogTexts(
128-
client: AppStoreConnectClient,
129-
artifacts: CiArtifact[],
130-
): Promise<string[]> {
131-
const parsedLogTexts: string[] = [];
132-
133-
for (const artifact of artifacts) {
134-
const downloadUrl = artifact.attributes.downloadUrl;
135-
136-
if (!downloadUrl) {
137-
continue;
138-
}
139-
140-
try {
141-
const artifactBytes = await client.artifacts.downloadArtifact(downloadUrl);
142-
const extractedText = extractTextFromArtifact(
143-
artifact.attributes.fileName,
144-
artifactBytes,
145-
);
146-
147-
if (extractedText) {
148-
parsedLogTexts.push(extractedText);
149-
}
150-
} catch {
151-
continue;
152-
}
153-
}
154-
155-
return parsedLogTexts;
156-
}

src/tools/tests.ts

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {
88
} from '../utils/build-locator.js';
99
import { collectBuildRunArtifacts } from '../utils/build-artifacts.js';
1010
import {
11-
extractTextFromArtifact,
1211
summarizeLogTexts,
1312
} from '../utils/log-analysis.js';
13+
import { storeLogArtifacts } from '../utils/log-storage.js';
1414
import { errorResponse, jsonResponse } from '../utils/tool-response.js';
1515

1616
interface BuildLookupInput {
@@ -38,11 +38,12 @@ export function registerTestTools(
3838
try {
3939
const buildRun = await resolveBuildLocator(client, input);
4040
const groupedArtifacts = await collectBuildRunArtifacts(client, buildRun.id);
41-
const parsedLogTexts = await downloadLogTexts(
41+
const storedLogs = await storeLogArtifacts(
4242
client,
43+
buildRun.id,
4344
groupedArtifacts.logs,
4445
);
45-
const logSummary = summarizeLogTexts(parsedLogTexts, 4000);
46+
const logSummary = summarizeLogTexts(storedLogs.parsedLogTexts, 2000);
4647

4748
return jsonResponse({
4849
buildRun: {
@@ -58,11 +59,14 @@ export function registerTestTools(
5859
warnings: 0,
5960
},
6061
resultBundles: groupedArtifacts.resultBundles.map(formatArtifact),
62+
savedLogsDirectory: storedLogs.directoryPath,
63+
savedLogs: storedLogs.savedLogFiles,
6164
summary: {
6265
parsedLogArtifactCount: logSummary.summary.parsedArtifactCount,
6366
errorHighlightCount: logSummary.summary.errorHighlightCount,
6467
warningHighlightCount: logSummary.summary.warningHighlightCount,
6568
},
69+
failedTests: logSummary.failedTests,
6670
highlights: logSummary.highlights,
6771
});
6872
} catch (error) {
@@ -118,34 +122,3 @@ function formatArtifact(artifact: CiArtifact) {
118122
downloadUrl: artifact.attributes.downloadUrl,
119123
};
120124
}
121-
122-
async function downloadLogTexts(
123-
client: AppStoreConnectClient,
124-
artifacts: CiArtifact[],
125-
): Promise<string[]> {
126-
const parsedLogTexts: string[] = [];
127-
128-
for (const artifact of artifacts) {
129-
const downloadUrl = artifact.attributes.downloadUrl;
130-
131-
if (!downloadUrl) {
132-
continue;
133-
}
134-
135-
try {
136-
const artifactBytes = await client.artifacts.downloadArtifact(downloadUrl);
137-
const extractedText = extractTextFromArtifact(
138-
artifact.attributes.fileName,
139-
artifactBytes,
140-
);
141-
142-
if (extractedText) {
143-
parsedLogTexts.push(extractedText);
144-
}
145-
} catch {
146-
continue;
147-
}
148-
}
149-
150-
return parsedLogTexts;
151-
}

src/utils/log-analysis.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export interface LogSummary {
77
errorHighlightCount: number;
88
warningHighlightCount: number;
99
};
10+
failedTests: Array<{
11+
testName: string;
12+
message?: string;
13+
}>;
1014
highlights: string[];
1115
excerpt: string;
1216
}
@@ -65,11 +69,16 @@ export function summarizeLogTexts(
6569
): LogSummary {
6670
const normalizedMaxCharacters = Math.max(500, maxCharacters);
6771
const highlightSet = new Set<string>();
72+
const failedTests = new Map<string, { testName: string; message?: string }>();
6873
let warningHighlightCount = 0;
6974
let errorHighlightCount = 0;
7075

7176
for (const parsedLogText of parsedLogTexts) {
72-
for (const line of parsedLogText.split(/\r?\n/)) {
77+
const lines = parsedLogText.split(/\r?\n/);
78+
79+
collectFailedTests(lines, failedTests);
80+
81+
for (const line of lines) {
7382
const normalizedLine = line.trim();
7483

7584
if (!normalizedLine) {
@@ -93,20 +102,84 @@ export function summarizeLogTexts(
93102
}
94103
}
95104

96-
const combinedText = parsedLogTexts.join('\n\n');
97-
const excerpt = combinedText.slice(0, normalizedMaxCharacters);
105+
const excerptSource = [
106+
...[...failedTests.values()].flatMap((failedTest) =>
107+
failedTest.message
108+
? [`${failedTest.testName}: ${failedTest.message}`]
109+
: [failedTest.testName],
110+
),
111+
...highlightSet,
112+
].join('\n');
113+
const excerpt = excerptSource.slice(0, normalizedMaxCharacters);
98114

99115
return {
100116
summary: {
101117
parsedArtifactCount: parsedLogTexts.length,
102118
errorHighlightCount,
103119
warningHighlightCount,
104120
},
121+
failedTests: [...failedTests.values()].sort(
122+
(leftFailure, rightFailure) =>
123+
Number(Boolean(rightFailure.message)) - Number(Boolean(leftFailure.message)),
124+
),
105125
highlights: [...highlightSet],
106126
excerpt,
107127
};
108128
}
109129

130+
function collectFailedTests(
131+
lines: string[],
132+
failedTests: Map<string, { testName: string; message?: string }>,
133+
): void {
134+
for (let index = 0; index < lines.length; index += 1) {
135+
const line = lines[index]?.trim();
136+
137+
if (!line) {
138+
continue;
139+
}
140+
141+
const swiftTestingMatch = line.match(/([A-Za-z0-9_]+\(.*\)) failed with:?$/i);
142+
143+
if (swiftTestingMatch?.[1]) {
144+
const testName = swiftTestingMatch[1];
145+
const message = lines[index + 1]?.trim();
146+
failedTests.set(testName, {
147+
testName,
148+
message: message && !isNoiseLine(message) ? message : undefined,
149+
});
150+
continue;
151+
}
152+
153+
const swiftTestingIssueMatch = line.match(
154+
/Test ([A-Za-z0-9_]+\(.*\)) recorded an issue .*?: (.+)$/i,
155+
);
156+
157+
if (swiftTestingIssueMatch?.[1] && swiftTestingIssueMatch?.[2]) {
158+
failedTests.set(swiftTestingIssueMatch[1], {
159+
testName: swiftTestingIssueMatch[1],
160+
message: swiftTestingIssueMatch[2],
161+
});
162+
continue;
163+
}
164+
165+
const xctestMatch = line.match(
166+
/Test Case .*?([A-Za-z0-9_]+)\]' failed/i,
167+
);
168+
169+
if (xctestMatch?.[1]) {
170+
const testName = `${xctestMatch[1]}()`;
171+
const nextLine = lines[index + 1]?.trim();
172+
const currentRecord = failedTests.get(testName) ?? { testName };
173+
174+
if (nextLine && isAssertionLine(nextLine)) {
175+
currentRecord.message = nextLine;
176+
}
177+
178+
failedTests.set(testName, currentRecord);
179+
}
180+
}
181+
}
182+
110183
function decodeTextBuffer(buffer: Uint8Array): string | null {
111184
try {
112185
const decodedText = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
@@ -159,3 +232,11 @@ function isErrorLine(line: string): boolean {
159232
function isWarningLine(line: string): boolean {
160233
return /warning:/i.test(line);
161234
}
235+
236+
function isNoiseLine(line: string): boolean {
237+
return /^Test Case /.test(line) || /^===== /.test(line);
238+
}
239+
240+
function isAssertionLine(line: string): boolean {
241+
return /expectation failed|assert|error:/i.test(line);
242+
}

src/utils/log-storage.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { mkdir, rm, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import path from 'node:path';
4+
import type { AppStoreConnectClient } from '../api/client.js';
5+
import type { CiArtifact } from '../api/types.js';
6+
import { extractTextFromArtifact } from './log-analysis.js';
7+
8+
export interface SavedLogFile {
9+
artifactId: string;
10+
fileName: string;
11+
downloadUrl?: string;
12+
rawPath: string;
13+
textPath?: string;
14+
}
15+
16+
export interface StoredLogs {
17+
directoryPath: string;
18+
parsedLogTexts: string[];
19+
savedLogFiles: SavedLogFile[];
20+
}
21+
22+
/**
23+
* Download log artifacts, persist them locally, and return extracted text when available.
24+
*/
25+
export async function storeLogArtifacts(
26+
client: AppStoreConnectClient,
27+
buildRunId: string,
28+
artifacts: CiArtifact[],
29+
): Promise<StoredLogs> {
30+
const directoryPath = path.join(tmpdir(), 'xcode-cloud-mcp', 'build-logs', buildRunId);
31+
await rm(directoryPath, { recursive: true, force: true });
32+
await mkdir(directoryPath, { recursive: true });
33+
34+
const parsedLogTexts: string[] = [];
35+
const savedLogFiles: SavedLogFile[] = [];
36+
37+
for (const artifact of artifacts) {
38+
const downloadUrl = artifact.attributes.downloadUrl;
39+
40+
if (!downloadUrl) {
41+
continue;
42+
}
43+
44+
try {
45+
const artifactBytes = await client.artifacts.downloadArtifact(downloadUrl);
46+
const safeBaseName = sanitizeFileName(artifact.attributes.fileName);
47+
const rawPath = path.join(directoryPath, safeBaseName);
48+
await writeFile(rawPath, artifactBytes);
49+
50+
const savedLogFile: SavedLogFile = {
51+
artifactId: artifact.id,
52+
fileName: artifact.attributes.fileName,
53+
downloadUrl,
54+
rawPath,
55+
};
56+
57+
const extractedText = extractTextFromArtifact(
58+
artifact.attributes.fileName,
59+
artifactBytes,
60+
);
61+
62+
if (extractedText) {
63+
const textPath = path.join(directoryPath, `${safeBaseName}.txt`);
64+
await writeFile(textPath, extractedText, 'utf8');
65+
savedLogFile.textPath = textPath;
66+
parsedLogTexts.push(extractedText);
67+
}
68+
69+
savedLogFiles.push(savedLogFile);
70+
} catch {
71+
continue;
72+
}
73+
}
74+
75+
return {
76+
directoryPath,
77+
parsedLogTexts,
78+
savedLogFiles,
79+
};
80+
}
81+
82+
function sanitizeFileName(fileName: string): string {
83+
return fileName.replace(/[\\/:\s]+/g, '-');
84+
}

tests/log-analysis.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,19 @@ test('extractTextFromArtifact reads zip content', () => {
3939

4040
test('summarizeLogTexts counts warnings and errors', () => {
4141
const summary = summarizeLogTexts(
42-
['warning: watch out\nerror: broken\ntest case Example failed'],
42+
[
43+
'displayExpiryDateReturnsFormattedDateWhenExpiryDateExists() failed with:\nExpectation failed: (displayDate?.contains("2025") → false) == true: Display date should contain the year\nwarning: watch out\nerror: broken\ntest case Example failed',
44+
],
4345
2000,
4446
);
4547

4648
assert.equal(summary.summary.parsedArtifactCount, 1);
4749
assert.equal(summary.summary.warningHighlightCount, 1);
48-
assert.equal(summary.summary.errorHighlightCount, 2);
50+
assert.equal(summary.summary.errorHighlightCount, 4);
51+
assert.equal(summary.failedTests[0]?.testName, 'displayExpiryDateReturnsFormattedDateWhenExpiryDateExists()');
52+
assert.match(
53+
summary.failedTests[0]?.message ?? '',
54+
/Display date should contain the year/,
55+
);
4956
assert.match(summary.excerpt, /warning: watch out/);
5057
});

0 commit comments

Comments
 (0)