Skip to content

Commit 82df18d

Browse files
committed
Add tests and CI workflows
1 parent 707d535 commit 82df18d

8 files changed

Lines changed: 546 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
name: CI
3+
4+
on:
5+
push:
6+
branches:
7+
- main
8+
pull_request:
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
test:
16+
name: Test
17+
runs-on: [self-hosted, macOS]
18+
steps:
19+
- name: Checkout Repository
20+
uses: actions/checkout@v6
21+
with:
22+
clean: true
23+
24+
- name: Setup Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: 22
28+
cache: npm
29+
30+
- name: Install Dependencies
31+
run: npm ci
32+
33+
- name: Run Tests
34+
run: npm test

.github/workflows/nightly.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
name: Nightly Tests
3+
4+
on:
5+
schedule:
6+
- cron: '0 4 * * *'
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
nightly_tests:
14+
name: Nightly Tests
15+
runs-on: [self-hosted, macOS]
16+
steps:
17+
- name: Checkout Repository
18+
uses: actions/checkout@v6
19+
with:
20+
clean: true
21+
22+
- name: Setup Node.js
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version: 22
26+
cache: npm
27+
28+
- name: Install Dependencies
29+
run: npm ci
30+
31+
- name: Run Tests
32+
run: npm test

tests/artifacts.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { groupArtifactsByType } from '../src/api/resources/artifacts.js';
4+
import type { CiArtifact } from '../src/api/types.js';
5+
6+
test('groupArtifactsByType classifies known artifact types', () => {
7+
const groupedArtifacts = groupArtifactsByType([
8+
createArtifact('1', 'LOG'),
9+
createArtifact('2', 'SCREENSHOT'),
10+
createArtifact('3', 'VIDEO'),
11+
createArtifact('4', 'RESULT_BUNDLE'),
12+
createArtifact('5', 'TEST_PRODUCTS'),
13+
createArtifact('6', 'ARCHIVE'),
14+
]);
15+
16+
assert.equal(groupedArtifacts.logs.length, 1);
17+
assert.equal(groupedArtifacts.screenshots.length, 1);
18+
assert.equal(groupedArtifacts.videos.length, 1);
19+
assert.equal(groupedArtifacts.resultBundles.length, 1);
20+
assert.equal(groupedArtifacts.testProducts.length, 1);
21+
assert.equal(groupedArtifacts.archives.length, 1);
22+
});
23+
24+
function createArtifact(
25+
id: string,
26+
fileType: CiArtifact['attributes']['fileType'],
27+
): CiArtifact {
28+
return {
29+
id,
30+
type: 'ciArtifacts',
31+
attributes: {
32+
fileName: `${id}.txt`,
33+
fileType,
34+
downloadUrl: `https://example.com/${id}`,
35+
},
36+
};
37+
}

tests/build-locator.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import {
4+
isFailureStatus,
5+
resolveBuildLocator,
6+
sortBuildRuns,
7+
validateBuildLocator,
8+
} from '../src/utils/build-locator.js';
9+
import type { AppStoreConnectClient } from '../src/api/client.js';
10+
import type { CiBuildRun } from '../src/api/types.js';
11+
12+
const buildRuns: CiBuildRun[] = [
13+
createBuildRun('run-1', 1, 'SUCCEEDED', '2026-03-30T09:00:00Z'),
14+
createBuildRun('run-2', 2, 'FAILED', '2026-03-30T10:00:00Z'),
15+
createBuildRun('run-3', 3, 'ERRORED', '2026-03-30T11:00:00Z'),
16+
];
17+
18+
test('validateBuildLocator accepts buildRunId only', () => {
19+
const locator = validateBuildLocator({
20+
buildRunId: 'xcode-cloud://build-run/run-123',
21+
});
22+
23+
assert.deepEqual(locator, {
24+
buildRunId: 'run-123',
25+
});
26+
});
27+
28+
test('validateBuildLocator accepts workflowId with build number', () => {
29+
const locator = validateBuildLocator({
30+
workflowId: 'xcode-cloud://workflow/wf-1',
31+
buildNumber: 42,
32+
});
33+
34+
assert.deepEqual(locator, {
35+
workflowId: 'wf-1',
36+
buildNumber: 42,
37+
});
38+
});
39+
40+
test('validateBuildLocator rejects mixed lookup modes', () => {
41+
assert.throws(
42+
() =>
43+
validateBuildLocator({
44+
buildRunId: 'run-1',
45+
workflowId: 'wf-1',
46+
}),
47+
{
48+
message:
49+
'Provide either buildRunId or workflowId with buildNumber/buildSelector, not both.',
50+
},
51+
);
52+
});
53+
54+
test('sortBuildRuns returns newest build first', () => {
55+
assert.deepEqual(
56+
sortBuildRuns(buildRuns).map((buildRun) => buildRun.id),
57+
['run-3', 'run-2', 'run-1'],
58+
);
59+
});
60+
61+
test('resolveBuildLocator resolves a concrete build number', async () => {
62+
const client = createClientMock();
63+
64+
const buildRun = await resolveBuildLocator(client, {
65+
workflowId: 'wf-1',
66+
buildNumber: 2,
67+
});
68+
69+
assert.equal(buildRun.id, 'run-2');
70+
});
71+
72+
test('resolveBuildLocator resolves latest failing build', async () => {
73+
const client = createClientMock();
74+
75+
const buildRun = await resolveBuildLocator(client, {
76+
workflowId: 'wf-1',
77+
buildSelector: 'latestFailing',
78+
});
79+
80+
assert.equal(buildRun.id, 'run-3');
81+
});
82+
83+
test('isFailureStatus matches failed and errored runs only', () => {
84+
assert.equal(isFailureStatus('FAILED'), true);
85+
assert.equal(isFailureStatus('ERRORED'), true);
86+
assert.equal(isFailureStatus('SUCCEEDED'), false);
87+
assert.equal(isFailureStatus('CANCELED'), false);
88+
});
89+
90+
function createClientMock(): AppStoreConnectClient {
91+
return {
92+
builds: {
93+
getById: async (buildRunId: string) =>
94+
buildRuns.find((buildRun) => buildRun.id === buildRunId)!,
95+
listForWorkflow: async () => buildRuns,
96+
},
97+
} as unknown as AppStoreConnectClient;
98+
}
99+
100+
function createBuildRun(
101+
id: string,
102+
number: number,
103+
completionStatus: CiBuildRun['attributes']['completionStatus'],
104+
createdDate: string,
105+
): CiBuildRun {
106+
return {
107+
id,
108+
type: 'ciBuildRuns',
109+
attributes: {
110+
number,
111+
createdDate,
112+
executionProgress: 'COMPLETE',
113+
completionStatus,
114+
isPullRequestBuild: false,
115+
issueCounts: {
116+
analyzerWarnings: 0,
117+
errors: completionStatus === 'SUCCEEDED' ? 0 : 1,
118+
testFailures: completionStatus === 'SUCCEEDED' ? 0 : 2,
119+
warnings: 1,
120+
},
121+
},
122+
relationships: {
123+
workflow: {
124+
data: {
125+
type: 'ciWorkflows',
126+
id: 'wf-1',
127+
},
128+
},
129+
},
130+
};
131+
}

tests/env.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { loadEnvironment } from '../src/env.js';
4+
5+
test('loadEnvironment reads primary env names', () => {
6+
const environment = loadEnvironment({
7+
APPSTORE_CONNECT_API_KEY_ID: 'key-id',
8+
APPSTORE_CONNECT_API_ISSUER_ID: 'issuer-id',
9+
APPSTORE_CONNECT_API_KEY_CONTENT: '"line-one\\nline-two"',
10+
});
11+
12+
assert.equal(environment.keyId, 'key-id');
13+
assert.equal(environment.issuerId, 'issuer-id');
14+
assert.equal(environment.privateKey, 'line-one\nline-two');
15+
});
16+
17+
test('loadEnvironment falls back to alias names', () => {
18+
const environment = loadEnvironment({
19+
APP_STORE_KEY_ID: 'alias-key',
20+
APP_STORE_ISSUER_ID: 'alias-issuer',
21+
APP_STORE_PRIVATE_KEY: 'alias-private-key',
22+
});
23+
24+
assert.equal(environment.keyId, 'alias-key');
25+
assert.equal(environment.issuerId, 'alias-issuer');
26+
assert.equal(environment.privateKey, 'alias-private-key');
27+
});
28+
29+
test('loadEnvironment prefers primary values over aliases', () => {
30+
const environment = loadEnvironment({
31+
APPSTORE_CONNECT_API_KEY_ID: 'primary-key',
32+
APP_STORE_KEY_ID: 'alias-key',
33+
APPSTORE_CONNECT_API_ISSUER_ID: 'primary-issuer',
34+
APP_STORE_ISSUER_ID: 'alias-issuer',
35+
APPSTORE_CONNECT_API_KEY_CONTENT: 'primary-key-content',
36+
APP_STORE_PRIVATE_KEY: 'alias-key-content',
37+
});
38+
39+
assert.equal(environment.keyId, 'primary-key');
40+
assert.equal(environment.issuerId, 'primary-issuer');
41+
assert.equal(environment.privateKey, 'primary-key-content');
42+
});
43+
44+
test('loadEnvironment throws a clear error when values are missing', () => {
45+
assert.throws(() => loadEnvironment({}), {
46+
message:
47+
'Missing required environment variables: APPSTORE_CONNECT_API_KEY_ID or APP_STORE_KEY_ID, APPSTORE_CONNECT_API_ISSUER_ID or APP_STORE_ISSUER_ID, APPSTORE_CONNECT_API_KEY_CONTENT or APP_STORE_PRIVATE_KEY',
48+
});
49+
});

tests/log-analysis.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { gzipSync } from 'node:zlib';
4+
import { zipSync } from 'fflate';
5+
import {
6+
extractTextFromArtifact,
7+
summarizeLogTexts,
8+
} from '../src/utils/log-analysis.js';
9+
10+
test('extractTextFromArtifact reads plain text', () => {
11+
const text = extractTextFromArtifact(
12+
'build.log',
13+
new TextEncoder().encode('warning: something\nerror: broken'),
14+
);
15+
16+
assert.equal(text, 'warning: something\nerror: broken');
17+
});
18+
19+
test('extractTextFromArtifact reads gzip content', () => {
20+
const text = extractTextFromArtifact(
21+
'build.log.gz',
22+
gzipSync('warning: compressed'),
23+
);
24+
25+
assert.equal(text, 'warning: compressed');
26+
});
27+
28+
test('extractTextFromArtifact reads zip content', () => {
29+
const text = extractTextFromArtifact(
30+
'logs.zip',
31+
zipSync({
32+
'a.log': new TextEncoder().encode('error: zipped failure'),
33+
'b.bin': new Uint8Array([0, 1, 2, 3]),
34+
}),
35+
);
36+
37+
assert.match(text ?? '', /error: zipped failure/);
38+
});
39+
40+
test('summarizeLogTexts counts warnings and errors', () => {
41+
const summary = summarizeLogTexts(
42+
['warning: watch out\nerror: broken\ntest case Example failed'],
43+
2000,
44+
);
45+
46+
assert.equal(summary.summary.parsedArtifactCount, 1);
47+
assert.equal(summary.summary.warningHighlightCount, 1);
48+
assert.equal(summary.summary.errorHighlightCount, 2);
49+
assert.match(summary.excerpt, /warning: watch out/);
50+
});

tests/smoke.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5+
6+
test('server starts over stdio and exposes the expected tools', async () => {
7+
const transport = new StdioClientTransport({
8+
command: 'node',
9+
args: ['--import', 'tsx', 'src/index.ts'],
10+
cwd: process.cwd(),
11+
env: {
12+
...process.env,
13+
APPSTORE_CONNECT_API_KEY_ID: 'dummy-key-id',
14+
APPSTORE_CONNECT_API_ISSUER_ID: 'dummy-issuer-id',
15+
APPSTORE_CONNECT_API_KEY_CONTENT:
16+
'-----BEGIN PRIVATE KEY-----\\ndummy\\n-----END PRIVATE KEY-----',
17+
},
18+
});
19+
20+
const client = new Client(
21+
{
22+
name: 'xcode-cloud-mcp-smoke-test',
23+
version: '0.1.0',
24+
},
25+
{
26+
capabilities: {},
27+
},
28+
);
29+
30+
await client.connect(transport);
31+
const tools = await client.listTools();
32+
const toolNames = tools.tools.map((tool) => tool.name).sort();
33+
34+
assert.deepEqual(toolNames, [
35+
'get_build_issues',
36+
'get_build_logs',
37+
'get_test_artifacts',
38+
'get_test_results',
39+
'list_build_runs',
40+
'list_products',
41+
'list_workflows',
42+
]);
43+
44+
await client.close();
45+
});

0 commit comments

Comments
 (0)