edsger 0.35.3 → 0.36.0
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/dist/api/app-store-iap.d.ts +60 -0
- package/dist/api/app-store-iap.js +145 -0
- package/dist/api/app-store.d.ts +12 -0
- package/dist/api/app-store.js +25 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +303 -0
- package/dist/commands/build/index.d.ts +59 -0
- package/dist/commands/build/index.js +519 -0
- package/dist/index.js +36 -0
- package/dist/phases/app-store-generation/agent.js +25 -19
- package/package.json +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the runBuild orchestration function.
|
|
3
|
+
* Uses dependency injection (BuildDeps) to mock external calls
|
|
4
|
+
* while testing the full decision-making pipeline.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
11
|
+
import { runBuild } from '../index.js';
|
|
12
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
13
|
+
function createTmpDir() {
|
|
14
|
+
const dir = join(tmpdir(), `edsger-run-build-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
function makeAppleConfig(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
id: 'config-1',
|
|
21
|
+
product_id: 'prod-1',
|
|
22
|
+
store_type: 'apple_app_store',
|
|
23
|
+
credentials: {
|
|
24
|
+
key_id: 'KEY123',
|
|
25
|
+
issuer_id: 'ISSUER456',
|
|
26
|
+
private_key: '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----',
|
|
27
|
+
},
|
|
28
|
+
app_identifier: 'com.example.app',
|
|
29
|
+
listings: {},
|
|
30
|
+
screenshots: {},
|
|
31
|
+
build_config: {},
|
|
32
|
+
current_version: null,
|
|
33
|
+
submission_status: 'none',
|
|
34
|
+
submitted_at: null,
|
|
35
|
+
released_at: null,
|
|
36
|
+
rejection_reason: null,
|
|
37
|
+
is_active: true,
|
|
38
|
+
created_by: 'user-1',
|
|
39
|
+
created_at: '2024-01-01',
|
|
40
|
+
updated_at: '2024-01-01',
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function makeOptions(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
buildProductId: 'prod-1',
|
|
47
|
+
verbose: false,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function createMockDeps(overrides = {}) {
|
|
52
|
+
const calls = [];
|
|
53
|
+
const savedConfigs = [];
|
|
54
|
+
const tmpDir = overrides.tmpDir ?? createTmpDir();
|
|
55
|
+
// Create a fake .ipa in the export directory so the build flow completes
|
|
56
|
+
const exportDir = join(tmpDir, 'build', 'export');
|
|
57
|
+
mkdirSync(exportDir, { recursive: true });
|
|
58
|
+
writeFileSync(join(exportDir, 'MyApp.ipa'), 'fake-ipa-data');
|
|
59
|
+
const deps = {
|
|
60
|
+
fetchConfigs: overrides.fetchConfigs ??
|
|
61
|
+
(async () => overrides.configs ?? [makeAppleConfig()]),
|
|
62
|
+
fetchGitHub: overrides.fetchGitHub ??
|
|
63
|
+
(async () => ({
|
|
64
|
+
configured: true,
|
|
65
|
+
token: 'ghp_test',
|
|
66
|
+
owner: 'org',
|
|
67
|
+
repo: 'app',
|
|
68
|
+
})),
|
|
69
|
+
cloneRepo: overrides.cloneRepo ?? (() => ({ repoPath: tmpDir })),
|
|
70
|
+
getWorkspaceRoot: overrides.getWorkspaceRoot ?? (() => tmpDir),
|
|
71
|
+
saveBuildConfig: overrides.saveBuildConfig ??
|
|
72
|
+
(async (_id, bc) => {
|
|
73
|
+
savedConfigs.push(bc);
|
|
74
|
+
return null;
|
|
75
|
+
}),
|
|
76
|
+
checkoutDefaultBranch: overrides.checkoutDefaultBranch ?? (() => { }),
|
|
77
|
+
findProjects: overrides.findProjects ??
|
|
78
|
+
(() => overrides.projects ?? [
|
|
79
|
+
{ path: 'MyApp.xcworkspace', type: 'workspace' },
|
|
80
|
+
]),
|
|
81
|
+
findSchemes: overrides.findSchemes ?? (() => overrides.schemes ?? ['MyApp']),
|
|
82
|
+
runCommand: overrides.runCommand ??
|
|
83
|
+
(async (cmd, args, opts) => {
|
|
84
|
+
calls.push({ cmd, args, opts });
|
|
85
|
+
}),
|
|
86
|
+
isXcodeAvailable: overrides.isXcodeAvailable ?? (() => true),
|
|
87
|
+
};
|
|
88
|
+
return { deps, calls, savedConfigs };
|
|
89
|
+
}
|
|
90
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
91
|
+
describe('runBuild orchestration', () => {
|
|
92
|
+
let tmpDir;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tmpDir = createTmpDir();
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
// -- Error paths --
|
|
100
|
+
it('throws when xcodebuild is not available', async () => {
|
|
101
|
+
const { deps } = createMockDeps({
|
|
102
|
+
tmpDir,
|
|
103
|
+
isXcodeAvailable: () => false,
|
|
104
|
+
});
|
|
105
|
+
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
106
|
+
assert.ok(err.message.includes('xcodebuild is not available'));
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
it('throws when no Apple App Store config exists', async () => {
|
|
111
|
+
const { deps } = createMockDeps({
|
|
112
|
+
tmpDir,
|
|
113
|
+
configs: [],
|
|
114
|
+
});
|
|
115
|
+
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
116
|
+
assert.ok(err.message.includes('No Apple App Store configuration'));
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it('throws when GitHub is not configured', async () => {
|
|
121
|
+
const { deps } = createMockDeps({
|
|
122
|
+
tmpDir,
|
|
123
|
+
fetchGitHub: async () => ({
|
|
124
|
+
configured: false,
|
|
125
|
+
message: 'No repo connected',
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
129
|
+
assert.ok(err.message.includes('GitHub not configured'));
|
|
130
|
+
return true;
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
it('throws when no Xcode projects found', async () => {
|
|
134
|
+
const { deps } = createMockDeps({
|
|
135
|
+
tmpDir,
|
|
136
|
+
projects: [],
|
|
137
|
+
});
|
|
138
|
+
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
139
|
+
assert.ok(err.message.includes('No Xcode projects found'));
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
it('throws when no schemes found', async () => {
|
|
144
|
+
const { deps } = createMockDeps({
|
|
145
|
+
tmpDir,
|
|
146
|
+
schemes: [],
|
|
147
|
+
});
|
|
148
|
+
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
149
|
+
assert.ok(err.message.includes('No schemes found'));
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
// -- Happy paths --
|
|
154
|
+
it('runs full archive + export pipeline', async () => {
|
|
155
|
+
const config = makeAppleConfig({
|
|
156
|
+
build_config: { team_id: 'TEAM1', export_method: 'app-store' },
|
|
157
|
+
});
|
|
158
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
159
|
+
await runBuild(makeOptions(), deps);
|
|
160
|
+
// Should have called xcodebuild twice: archive + exportArchive
|
|
161
|
+
const xcodeCalls = calls.filter((c) => c.cmd === 'xcodebuild');
|
|
162
|
+
assert.strictEqual(xcodeCalls.length, 2);
|
|
163
|
+
assert.ok(xcodeCalls[0].args.includes('archive'));
|
|
164
|
+
assert.ok(xcodeCalls[1].args.includes('-exportArchive'));
|
|
165
|
+
});
|
|
166
|
+
it('uses scheme from options over saved config', async () => {
|
|
167
|
+
const config = makeAppleConfig({
|
|
168
|
+
build_config: { scheme: 'SavedScheme', project_path: 'A.xcworkspace' },
|
|
169
|
+
});
|
|
170
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
171
|
+
await runBuild(makeOptions({ scheme: 'OverrideScheme' }), deps);
|
|
172
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
173
|
+
assert.ok(archiveCall);
|
|
174
|
+
const schemeIdx = archiveCall.args.indexOf('-scheme');
|
|
175
|
+
assert.strictEqual(archiveCall.args[schemeIdx + 1], 'OverrideScheme');
|
|
176
|
+
});
|
|
177
|
+
it('uses project path from saved build_config', async () => {
|
|
178
|
+
const config = makeAppleConfig({
|
|
179
|
+
build_config: { project_path: 'ios/App.xcworkspace', scheme: 'App' },
|
|
180
|
+
});
|
|
181
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
182
|
+
await runBuild(makeOptions(), deps);
|
|
183
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
184
|
+
assert.ok(archiveCall);
|
|
185
|
+
assert.ok(archiveCall.args.some((a) => a.includes('ios/App.xcworkspace')));
|
|
186
|
+
});
|
|
187
|
+
it('persists discovered build config to DB', async () => {
|
|
188
|
+
const { deps, savedConfigs } = createMockDeps({ tmpDir });
|
|
189
|
+
await runBuild(makeOptions(), deps);
|
|
190
|
+
assert.strictEqual(savedConfigs.length, 1);
|
|
191
|
+
assert.strictEqual(savedConfigs[0].project_path, 'MyApp.xcworkspace');
|
|
192
|
+
assert.strictEqual(savedConfigs[0].scheme, 'MyApp');
|
|
193
|
+
assert.strictEqual(savedConfigs[0].configuration, 'Release');
|
|
194
|
+
});
|
|
195
|
+
it('does not re-save config when nothing changed', async () => {
|
|
196
|
+
const config = makeAppleConfig({
|
|
197
|
+
build_config: {
|
|
198
|
+
project_path: 'MyApp.xcworkspace',
|
|
199
|
+
scheme: 'MyApp',
|
|
200
|
+
configuration: 'Release',
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const { deps, savedConfigs } = createMockDeps({
|
|
204
|
+
tmpDir,
|
|
205
|
+
configs: [config],
|
|
206
|
+
});
|
|
207
|
+
await runBuild(makeOptions(), deps);
|
|
208
|
+
assert.strictEqual(savedConfigs.length, 0);
|
|
209
|
+
});
|
|
210
|
+
it('passes --upload to trigger upload after build', async () => {
|
|
211
|
+
const config = makeAppleConfig({
|
|
212
|
+
build_config: {
|
|
213
|
+
project_path: 'A.xcworkspace',
|
|
214
|
+
scheme: 'A',
|
|
215
|
+
configuration: 'Release',
|
|
216
|
+
team_id: 'TEAM1',
|
|
217
|
+
export_method: 'app-store',
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
221
|
+
await runBuild(makeOptions({ upload: true }), deps);
|
|
222
|
+
// Should have xcrun altool call for upload
|
|
223
|
+
const uploadCall = calls.find((c) => c.cmd === 'xcrun' && c.args.includes('altool'));
|
|
224
|
+
assert.ok(uploadCall, 'Expected xcrun altool upload call');
|
|
225
|
+
assert.ok(uploadCall.args.includes('--upload-app'));
|
|
226
|
+
});
|
|
227
|
+
it('uses notarytool for macOS platform upload', async () => {
|
|
228
|
+
const config = makeAppleConfig({
|
|
229
|
+
build_config: {
|
|
230
|
+
project_path: 'A.xcworkspace',
|
|
231
|
+
scheme: 'A',
|
|
232
|
+
configuration: 'Release',
|
|
233
|
+
team_id: 'TEAM1',
|
|
234
|
+
export_method: 'app-store',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
238
|
+
await runBuild(makeOptions({ upload: true, platform: 'macos' }), deps);
|
|
239
|
+
const uploadCall = calls.find((c) => c.cmd === 'xcrun' && c.args.includes('notarytool'));
|
|
240
|
+
assert.ok(uploadCall, 'Expected xcrun notarytool call for macOS');
|
|
241
|
+
});
|
|
242
|
+
it('passes platform destination to xcodebuild', async () => {
|
|
243
|
+
const { deps, calls } = createMockDeps({ tmpDir });
|
|
244
|
+
await runBuild(makeOptions({ platform: 'macos' }), deps);
|
|
245
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
246
|
+
assert.ok(archiveCall);
|
|
247
|
+
const destIdx = archiveCall.args.indexOf('-destination');
|
|
248
|
+
assert.strictEqual(archiveCall.args[destIdx + 1], 'generic/platform=macOS');
|
|
249
|
+
});
|
|
250
|
+
it('auto-selects first workspace when multiple projects exist', async () => {
|
|
251
|
+
const { deps, calls } = createMockDeps({
|
|
252
|
+
tmpDir,
|
|
253
|
+
projects: [
|
|
254
|
+
{ path: 'ios/App.xcworkspace', type: 'workspace' },
|
|
255
|
+
{ path: 'App.xcodeproj', type: 'project' },
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
await runBuild(makeOptions(), deps);
|
|
259
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
260
|
+
assert.ok(archiveCall);
|
|
261
|
+
assert.ok(archiveCall.args.some((a) => a.includes('ios/App.xcworkspace')));
|
|
262
|
+
});
|
|
263
|
+
it('skips archive when --skipArchive is set', async () => {
|
|
264
|
+
const { deps, calls } = createMockDeps({ tmpDir });
|
|
265
|
+
await runBuild(makeOptions({ skipArchive: true }), deps);
|
|
266
|
+
const xcodeCalls = calls.filter((c) => c.cmd === 'xcodebuild');
|
|
267
|
+
assert.strictEqual(xcodeCalls.length, 0);
|
|
268
|
+
});
|
|
269
|
+
it('adds DEVELOPMENT_TEAM when team_id is set', async () => {
|
|
270
|
+
const config = makeAppleConfig({
|
|
271
|
+
build_config: {
|
|
272
|
+
project_path: 'A.xcworkspace',
|
|
273
|
+
scheme: 'A',
|
|
274
|
+
configuration: 'Release',
|
|
275
|
+
team_id: 'TEAM123',
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
279
|
+
await runBuild(makeOptions(), deps);
|
|
280
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
281
|
+
assert.ok(archiveCall);
|
|
282
|
+
assert.ok(archiveCall.args.some((a) => a === 'DEVELOPMENT_TEAM=TEAM123'));
|
|
283
|
+
});
|
|
284
|
+
it('re-discovers project on --reselect even with saved config', async () => {
|
|
285
|
+
const config = makeAppleConfig({
|
|
286
|
+
build_config: {
|
|
287
|
+
project_path: 'old/Old.xcworkspace',
|
|
288
|
+
scheme: 'OldScheme',
|
|
289
|
+
configuration: 'Release',
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const { deps, savedConfigs } = createMockDeps({
|
|
293
|
+
tmpDir,
|
|
294
|
+
configs: [config],
|
|
295
|
+
projects: [{ path: 'new/New.xcworkspace', type: 'workspace' }],
|
|
296
|
+
schemes: ['NewScheme'],
|
|
297
|
+
});
|
|
298
|
+
await runBuild(makeOptions({ reselect: true }), deps);
|
|
299
|
+
assert.strictEqual(savedConfigs.length, 1);
|
|
300
|
+
assert.strictEqual(savedConfigs[0].project_path, 'new/New.xcworkspace');
|
|
301
|
+
assert.strictEqual(savedConfigs[0].scheme, 'NewScheme');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type AppStoreConfig, type BuildConfig } from '../../api/app-store.js';
|
|
2
|
+
import { type CliOptions } from '../../types/index.js';
|
|
3
|
+
export interface BuildOptions extends CliOptions {
|
|
4
|
+
buildProductId: string;
|
|
5
|
+
scheme?: string;
|
|
6
|
+
project?: string;
|
|
7
|
+
configuration?: string;
|
|
8
|
+
platform?: string;
|
|
9
|
+
upload?: boolean;
|
|
10
|
+
reselect?: boolean;
|
|
11
|
+
skipArchive?: boolean;
|
|
12
|
+
ipa?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface XcodeProject {
|
|
15
|
+
path: string;
|
|
16
|
+
type: 'workspace' | 'project';
|
|
17
|
+
}
|
|
18
|
+
export type Platform = 'ios' | 'macos';
|
|
19
|
+
/**
|
|
20
|
+
* Injectable dependencies for runBuild — enables unit testing
|
|
21
|
+
* without real MCP, GitHub, git, or xcodebuild calls.
|
|
22
|
+
*/
|
|
23
|
+
export interface BuildDeps {
|
|
24
|
+
fetchConfigs: (productId: string, verbose?: boolean) => Promise<AppStoreConfig[]>;
|
|
25
|
+
fetchGitHub: (productId: string, verbose?: boolean) => Promise<{
|
|
26
|
+
configured: boolean;
|
|
27
|
+
token?: string;
|
|
28
|
+
owner?: string;
|
|
29
|
+
repo?: string;
|
|
30
|
+
message?: string;
|
|
31
|
+
}>;
|
|
32
|
+
cloneRepo: (workspaceRoot: string, dirName: string, owner: string, repo: string, token: string) => {
|
|
33
|
+
repoPath: string;
|
|
34
|
+
};
|
|
35
|
+
getWorkspaceRoot: () => string;
|
|
36
|
+
saveBuildConfig: (configId: string, buildConfig: BuildConfig, verbose?: boolean) => Promise<AppStoreConfig | null>;
|
|
37
|
+
checkoutDefaultBranch: (repoPath: string) => void;
|
|
38
|
+
findProjects: (rootDir: string) => XcodeProject[];
|
|
39
|
+
findSchemes: (repoPath: string, projectPath: string, projectType: 'workspace' | 'project') => string[];
|
|
40
|
+
runCommand: (cmd: string, args: string[], opts?: {
|
|
41
|
+
cwd?: string;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
timeout?: number;
|
|
44
|
+
}) => Promise<void>;
|
|
45
|
+
isXcodeAvailable: () => boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Default (real) implementations of all dependencies. */
|
|
48
|
+
export declare function createDefaultDeps(): BuildDeps;
|
|
49
|
+
export declare function findXcodeProjects(rootDir: string): XcodeProject[];
|
|
50
|
+
export declare function discoverSchemes(repoPath: string, projectPath: string, projectType: 'workspace' | 'project'): string[];
|
|
51
|
+
export declare function generateExportOptionsPlist(teamId: string, exportMethod: string): string;
|
|
52
|
+
export declare function writeApiKeyFile(keyId: string, privateKey: string): string;
|
|
53
|
+
export declare function cleanupApiKeyFile(keyId: string): void;
|
|
54
|
+
export declare function destinationForPlatform(platform: Platform): string;
|
|
55
|
+
/**
|
|
56
|
+
* Run the full build pipeline. Accepts an optional `deps` parameter
|
|
57
|
+
* for dependency injection in tests; falls back to real implementations.
|
|
58
|
+
*/
|
|
59
|
+
export declare function runBuild(options: BuildOptions, deps?: BuildDeps): Promise<void>;
|