import { afterAll, describe, expect, test } from 'bun:test'; import { createPrivateKey, createPublicKey, verify } from 'node:crypto'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installedProfiles } from '../cli/apple/build'; import { androidSigning, ascKeyFile, credentialFileIssues } from '../cli/lib/credentials'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../cli/lib/csr'; import { aabInfo, appBuildFile, findBundles, gradleArgs, pickGradle, signingEnv, validatePlayBuild, versionSourceIssues, versionSourceMessage } from '../cli/lib/gradle'; import { parseBinaryPlist, parseMobileprovision, parsePlist, parseXmlPlist, toXmlPlist } from '../cli/lib/plist'; import { type ApiCert, type ApiProfile, certSha1, certSpki, certificateReq, explainCertError, exportOptions, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, validateSigning, } from '../cli/lib/signing'; import { validateConfig } from '../cli/lib/config'; import { altoolArgs, archiveArgs, buildUploadReqs, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd, uploadProblems, type XcodeScan } from '../cli/lib/xcode'; import { zipEntries, zipRead } from '../cli/lib/zip'; import { makeAab, makeIpa, makeZip } from './build-fixtures'; import { TOOLS } from '../cli/mcp-tools'; const ROOT = join(import.meta.dir, '..'); const FIX = join(import.meta.dir, 'fixtures', 'build'); const tmp = mkdtempSync(join(tmpdir(), 'sb-build-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); const dir = (name: string): string => { const d = join(tmp, name); mkdirSync(d, { recursive: true }); return d; }; const touch = (base: string, rel: string, text = ''): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), text); }; describe('plist', () => { test('XML: write and read back every type', () => { const v = { method: 'app-store-connect', n: 3, x: 1.5, ok: true, no: false, list: ['a', 'b & '], nested: { k: 'v' }, empty: {}, data: Buffer.from([0, 1, 2]) }; const text = toXmlPlist(v); expect(text).toContain('method\n\tapp-store-connect'); expect(text).toContain('b & <c>'); expect(parseXmlPlist(text)).toEqual(v); }); test('the fixture Info.plist, XML and binary (plutil) agree', () => { const xml = parsePlist(readFileSync(join(FIX, 'Info.plist'))) as Record; const bin = parseBinaryPlist(readFileSync(join(FIX, 'Info.bplist'))) as Record; expect(xml.CFBundleIdentifier).toBe('com.example.game'); expect(bin.CFBundleDisplayName).toBe('Example Gäme'); expect(bin.UIDeviceFamily).toEqual([1, 2]); expect(bin.Scale).toBe(1.5); expect(bin.UIRequiresFullScreen).toBe(true); expect(bin.Built).toBe('2026-10-01T12:00:00.000Z'); expect(xml.Built).toBe('2026-10-01T12:00:00Z'); const { Built: _a, ...x } = xml, { Built: _b, ...b } = bin; expect(b).toEqual(x); }); test('mobileprovision: the plist inside the CMS envelope', () => { const inner = toXmlPlist({ UUID: '00000000-0000-0000-0000-000000000000', Name: 'storebound com.example.game App Store', TeamIdentifier: ['TEAMID1234'], ExpirationDate: '2099-01-01T00:00:00Z', Entitlements: { 'application-identifier': 'TEAMID1234.com.example.game', 'get-task-allow': false } }); const p = parseMobileprovision(Buffer.concat([Buffer.from([0x30, 0x82, 0x01]), Buffer.from(inner), Buffer.from([0xa0, 0x00])])); expect(p.Name).toBe('storebound com.example.game App Store'); expect(() => parseMobileprovision(Buffer.from('nope'))).toThrow('not a provisioning profile'); }); }); describe('zip + IPA + AAB', () => { test('zip entries, stored and deflated', () => { const z = makeZip([{ name: 'a.txt', data: 'hello', store: true }, { name: 'dir/b.txt', data: 'world '.repeat(100) }]); const es = zipEntries(z); expect(es.map(e => e.name)).toEqual(['a.txt', 'dir/b.txt']); expect(zipRead(z, es[1]!).toString()).toBe('world '.repeat(100)); expect(() => zipEntries(Buffer.from('not a zip at all, sorry'))).toThrow('not a ZIP'); }); test('ipaInfo reads binary and XML Info.plist', () => { const bin = makeIpa(readFileSync(join(FIX, 'Info.bplist'))); expect(ipaInfo(bin)).toEqual({ bundleId: 'com.example.game', version: '1.4.0', build: '42', minOs: '15.0', name: 'Example Gäme', platform: 'iPhoneOS' }); const f = join(dir('ipa'), 'Example.ipa'); writeFileSync(f, makeIpa(readFileSync(join(FIX, 'Info.plist')))); expect(ipaInfo(f).build).toBe('42'); expect(() => ipaInfo(makeZip([{ name: 'x', data: 'y' }]))).toThrow('not an iOS app archive'); }); test('aabInfo reads the manifest as AGP writes it (Resources.proto: element name = 3, attribute = 4, child = 5)', () => { // a real Capacitor 8 / AGP bundle failed with "AAB manifest without package / versionCode": the parser read the // element fields one number too high. These bytes are written by tag, independent of encodeProtoManifest. const len = (n: number) => Buffer.from(n < 128 ? [n] : [(n & 127) | 128, n >> 7]); const ld = (tag: number, b: Buffer) => Buffer.concat([Buffer.from([tag]), len(b.length), b]); const str = (tag: number, t: string) => ld(tag, Buffer.from(t)); const NS = 'http://schemas.android.com/apk/res/android'; const attr = (name: string, value: string, int?: number) => ld(0x22, Buffer.concat([name === 'package' ? Buffer.alloc(0) : str(0x0a, NS), str(0x12, name), str(0x1a, value), ...(int === undefined ? [] : [ld(0x32, ld(0x3a, Buffer.from([0x30, int])))])])); const usesSdk = ld(0x0a, Buffer.concat([str(0x1a, 'uses-sdk'), attr('minSdkVersion', '24', 24)])); const manifest = ld(0x0a, Buffer.concat([ld(0x0a, Buffer.concat([str(0x0a, 'android'), str(0x12, NS)])), str(0x1a, 'manifest'), attr('versionCode', '7', 7), attr('versionName', '1.0.0'), attr('package', 'com.example.game'), ld(0x2a, usesSdk)])); const aab = makeZip([{ name: 'base/manifest/AndroidManifest.xml', data: manifest }]); expect(aabInfo(aab)).toEqual({ packageName: 'com.example.game', versionCode: 7, versionName: '1.0.0', minSdk: 24, targetSdk: undefined }); }); test('aabInfo reads the proto manifest', () => { expect(aabInfo(makeAab('com.example.game', 1234, '2.0.1'))).toEqual({ packageName: 'com.example.game', versionCode: 1234, versionName: '2.0.1', minSdk: 24, targetSdk: 36 }); expect(() => aabInfo(makeZip([{ name: 'x', data: 'y' }]))).toThrow('not an Android App Bundle'); }); }); describe('CSR', () => { test('PKCS #10 with the public key, signed by the private key', () => { const { pem, key } = newSigningKey(); const csr = buildCsr(key, { commonName: 'storebound Example' }); expect(csr).toStartWith('-----BEGIN CERTIFICATE REQUEST-----'); const b64 = csrContent(csr); expect(b64).toMatch(/^[A-Za-z0-9+/=]+$/); const der = Buffer.from(b64, 'base64'); // the SPKI of the key is inside, and the signature verifies over CertificationRequestInfo const spki = createPublicKey(key).export({ type: 'spki', format: 'der' }); expect(der.includes(spki)).toBe(true); expect(spkiOfKey(pem)).toBe(spki.toString('base64')); const infoLenBytes = der[5]! & 0x80 ? (der[5]! & 0x7f) : 0; const infoStart = 4, infoLen = infoLenBytes ? der.readUIntBE(6, infoLenBytes) : der[5]!; const info = der.subarray(infoStart, infoStart + 2 + infoLenBytes + infoLen); const sig = der.subarray(der.length - 256); expect(verify('sha256', info, createPublicKey(createPrivateKey(pem)), sig)).toBe(true); if (Bun.which('openssl')) { const f = join(dir('csr'), 'req.csr'); writeFileSync(f, csr); const r = Bun.spawnSync(['openssl', 'req', '-in', f, '-noout', '-verify', '-subject'], { stdout: 'pipe', stderr: 'pipe' }); expect(r.exitCode).toBe(0); expect(r.stdout.toString() + r.stderr.toString()).toContain('storebound Example'); } }); }); const cert = (id: string, type: string, days: number, extra: Partial = {}): ApiCert => ({ id, attributes: { certificateType: type, expirationDate: new Date(Date.now() + days * 86_400_000).toISOString(), name: `Apple Distribution ${id}`, certificateContent: Buffer.from(id).toString('base64'), ...extra } }); describe('signing plans', () => { test('capabilities: adds the missing ones, never removes', () => { const p = planCapabilities('B1', ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS'], [{ type: 'IN_APP_PURCHASE' }, { type: 'GAME_CENTER' }, { type: 'ICLOUD', settings: [{ key: 'ICLOUD_VERSION', options: [{ key: 'XCODE_6' }] }] }]); expect(p.present).toEqual(['IN_APP_PURCHASE']); expect(p.extra).toEqual(['PUSH_NOTIFICATIONS']); expect(p.create.map(r => (r.body as any).data.attributes.capabilityType)).toEqual(['GAME_CENTER', 'ICLOUD']); expect((p.create[1]!.body as any).data.attributes.settings[0].key).toBe('ICLOUD_VERSION'); expect((p.create[0]!.body as any).data.relationships.bundleId.data).toEqual({ type: 'bundleIds', id: 'B1' }); }); test('certificate: newest usable one with a local key, not expiring', () => { const certs = [cert('old', 'DISTRIBUTION', 400), cert('new', 'DISTRIBUTION', 700), cert('soon', 'DISTRIBUTION', 10), cert('nokey', 'DISTRIBUTION', 900), cert('dev', 'DEVELOPMENT', 900), cert('gone', 'DISTRIBUTION', -1)]; const has = (c: ApiCert) => c.id !== 'nokey'; const c = pickCertificate(certs, 'DISTRIBUTION', has); expect(c.cert?.id).toBe('new'); expect(c.expiring.map(x => x.id)).toEqual(['soon']); expect(c.withoutKey.map(x => x.id)).toEqual(['nokey']); const none = pickCertificate([cert('nokey', 'DISTRIBUTION', 900)], 'DISTRIBUTION', () => false); expect(none.cert).toBeNull(); expect(none.why).toContain('none of their private keys'); expect(pickCertificate([], 'DISTRIBUTION', () => true).why).toBe('no valid DISTRIBUTION certificate'); expect(pickCertificate([cert('off', 'DISTRIBUTION', 500, { activated: false })], 'DISTRIBUTION', () => true).cert).toBeNull(); }); test('certificate request carries only the CSR', () => { const r = certificateReq('DISTRIBUTION', 'MIIB'); expect(r.body).toEqual({ data: { type: 'certificates', attributes: { certificateType: 'DISTRIBUTION', csrContent: 'MIIB' } } }); }); test('Developer ID 403, certificate limit and role errors are explained', () => { expect(explainCertError(new Error('POST /v1/certificates -> 403 FORBIDDEN_ERROR'), 'DEVELOPER_ID_APPLICATION')).toContain('Account Holder'); expect(explainCertError(new Error('POST /v1/certificates -> 403 FORBIDDEN_ERROR'), 'DISTRIBUTION')).toContain('Admin role'); expect(explainCertError(new Error('POST /v1/certificates -> 409 You already have a current Distribution certificate or a pending request (maximum)'), 'DISTRIBUTION')).toContain('limit'); expect(explainCertError(new Error('-> 500'), 'DISTRIBUTION')).toBeNull(); }); test('profile: reuse an active one with the certificate, else create', () => { const now = Date.now(); const p = (id: string, state: string, days: number, certIds: string[]): ApiProfile => ({ id, certIds, attributes: { name: id, profileType: 'IOS_APP_STORE', profileState: state, expirationDate: new Date(now + days * 86_400_000).toISOString(), uuid: `u-${id}` } }); const o = { bundleId: 'com.example.game', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const, now }; expect(planProfile([p('a', 'ACTIVE', 200, ['C1']), p('b', 'ACTIVE', 300, ['C1'])], o).reuse?.id).toBe('b'); const c = planProfile([p('x', 'INVALID', 200, ['C1']), p('y', 'ACTIVE', 300, ['C0']), p('z', 'ACTIVE', 5, ['C1'])], o); expect(c.reuse).toBeNull(); expect(c.why).toContain('none active with this certificate'); const body = (c.create!.body as any).data; expect(body.attributes.profileType).toBe('IOS_APP_STORE'); expect(body.attributes.name).toStartWith('storebound com.example.game App Store '); expect(body.relationships.certificates.data).toEqual([{ type: 'certificates', id: 'C1' }]); }); test('ExportOptions: app-store-connect, export destination, manual profiles', () => { expect(exportOptions({ teamId: 'TEAMID1234', style: 'automatic' })).toEqual({ method: 'app-store-connect', teamID: 'TEAMID1234', signingStyle: 'automatic', destination: 'export', uploadSymbols: true, manageAppVersionAndBuildNumber: false }); const m = exportOptions({ teamId: 'TEAMID1234', style: 'manual', profiles: { 'com.example.game': 'u-1' } }); expect(m.provisioningProfiles).toEqual({ 'com.example.game': 'u-1' }); expect(m.signingCertificate).toBe('Apple Distribution'); expect(() => exportOptions({ teamId: 'T', style: 'manual' })).toThrow('signing ensure'); expect(toXmlPlist(m)).toContain('signingStyle\n\tmanual'); }); test('identities, sha1, spki, profile folders', () => { const out = ' 1) 0123456789ABCDEF0123456789ABCDEF01234567 "Apple Distribution: Example (TEAMID1234)"\n 1 valid identities found\n'; expect(parseIdentities(out)).toEqual([{ sha1: '0123456789ABCDEF0123456789ABCDEF01234567', name: 'Apple Distribution: Example (TEAMID1234)' }]); expect(certSha1(Buffer.from('abc').toString('base64'))).toBe('A9993E364706816ABA3E25717850C26C9CD0D89D'); expect(certSpki('bm9wZQ==')).toBeNull(); expect(profileDirs('/Users/you')).toEqual(['/Users/you/Library/Developer/Xcode/UserData/Provisioning Profiles', '/Users/you/Library/MobileDevice/Provisioning Profiles']); }); test('config validation', () => { expect(validateSigning({ style: 'manual', teamId: 'TEAMID1234', capabilities: ['IN_APP_PURCHASE', { type: 'GAME_CENTER' }], extraBundleIds: ['com.example.game.widget'] })).toEqual([]); const e = validateSigning({ style: 'auto' as any, teamId: 'x', certificateType: 'NOPE' as any, capabilities: ['TELEPORT' as any], extraBundleIds: ['bad id'] }).join('\n'); for (const s of ['style', 'teamId', 'certificateType', 'TELEPORT', 'bad id']) expect(e).toContain(s); const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] }; expect(validateConfig({ ...base, apple: { bundleId: 'com.example.game', signing: { style: 'manual' }, build: { workspace: 'ios/App/App.xcworkspace', scheme: 'App' } }, play: { packageName: 'com.example.game', build: { module: 'app', flavor: 'prod' } }, originality: { against: ['..'], threshold: 0.7 } })).toEqual([]); expect(validateConfig({ ...base, apple: { bundleId: 'com.example.game', build: { workspace: 'App.xcodeproj' } } }).join()).toContain('.xcworkspace'); expect(validatePlayBuild({ module: 'a b', flavor: '1x' }).length).toBe(2); expect(validateConfig({ ...base, originality: { threshold: 2 } }).join()).toContain('originality.threshold'); }); test('installed App Store profiles are found by bundle id and team', () => { const home = dir('home'); const write = (uuid: string, appId: string, extra: Record = {}) => { const d = profileDirs(home)[0]!; mkdirSync(d, { recursive: true }); writeFileSync(join(d, `${uuid}.mobileprovision`), Buffer.concat([Buffer.from([0x30, 0x80]), Buffer.from(toXmlPlist({ UUID: uuid, Name: uuid, TeamIdentifier: ['TEAMID1234'], ExpirationDate: '2099-01-01T00:00:00Z', Entitlements: { 'application-identifier': appId, 'get-task-allow': false }, ...extra } as any))])); }; write('store', 'TEAMID1234.com.example.game'); write('dev', 'TEAMID1234.com.example.game', { ProvisionedDevices: ['x'] }); write('other', 'TEAMID1234.com.example.other'); expect(installedProfiles('com.example.game', 'TEAMID1234', home).map(p => p.uuid)).toEqual(['store']); expect(installedProfiles('com.example.game', 'OTHERTEAM1', home)).toEqual([]); }); }); describe('xcode projects', () => { const S = (o: Partial): XcodeScan => ({ markers: [], workspaces: [], projects: [], schemes: {}, pods: [], ...o }); test('Capacitor, Flutter, React Native, Unity, native', () => { expect(pickXcode(S({ markers: ['capacitor.config.ts'], workspaces: ['ios/App/App.xcworkspace'], projects: ['ios/App/App.xcodeproj', 'ios/App/Pods/Pods.xcodeproj'], pods: [{ dir: 'ios/App', installed: true }] }))) .toMatchObject({ framework: 'capacitor', workspace: 'ios/App/App.xcworkspace', scheme: 'App' }); expect(pickXcode(S({ markers: ['capacitor.config.json'], projects: ['ios/App/App.xcodeproj'] }))).toMatchObject({ project: 'ios/App/App.xcodeproj', scheme: 'App' }); expect(pickXcode(S({ markers: ['pubspec.yaml'], workspaces: ['ios/Runner.xcworkspace'], projects: ['ios/Runner.xcodeproj'] }))).toMatchObject({ framework: 'flutter', workspace: 'ios/Runner.xcworkspace', scheme: 'Runner' }); const rn = pickXcode(S({ markers: ['package.json:react-native'], workspaces: ['ios/ExampleGame.xcworkspace'], projects: ['ios/ExampleGame.xcodeproj'], schemes: { 'ios/ExampleGame.xcodeproj': ['ExampleGame', 'ExampleGame-tvOS'] }, pods: [{ dir: 'ios', installed: false }] })); expect(rn).toMatchObject({ framework: 'react-native', workspace: 'ios/ExampleGame.xcworkspace', scheme: 'ExampleGame' }); expect(rn.notes.join()).toContain('pod install'); expect(pickXcode(S({ markers: ['Unity-iPhone.xcodeproj'], projects: ['Builds/iOS/Unity-iPhone.xcodeproj'] }))).toMatchObject({ framework: 'unity', project: 'Builds/iOS/Unity-iPhone.xcodeproj', scheme: 'Unity-iPhone' }); expect(pickXcode(S({ projects: ['Example.xcodeproj'], schemes: { 'Example.xcodeproj': ['Example Release'] } }))).toMatchObject({ framework: 'native', project: 'Example.xcodeproj', scheme: 'Example Release' }); expect(pickXcode(S({ projects: ['Example.xcodeproj'] }), { scheme: 'Mine' }).scheme).toBe('Mine'); expect(() => pickXcode(S({}))).toThrow('no .xcworkspace'); expect(() => pickXcode(S({ projects: ['A.xcodeproj', 'B.xcodeproj'] }))).toThrow('several Xcode containers'); }); test('scanXcode walks a real folder (Capacitor layout)', () => { const d = dir('cap'); touch(d, 'capacitor.config.ts', 'export default {}'); touch(d, 'ios/App/App.xcodeproj/project.pbxproj'); touch(d, 'ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme'); touch(d, 'ios/App/App.xcworkspace/contents.xcworkspacedata'); touch(d, 'ios/App/Podfile'); touch(d, 'node_modules/x/ios/X.xcodeproj/project.pbxproj'); const s = scanXcode(d); expect(s.workspaces).toEqual(['ios/App/App.xcworkspace']); expect(s.projects).toEqual(['ios/App/App.xcodeproj']); expect(s.schemes['ios/App/App.xcodeproj']).toEqual(['App']); expect(s.pods).toEqual([{ dir: 'ios/App', installed: false }]); expect(pickXcode(s).notes.join()).toContain('pod install'); }); test('command lines: archive, export, flutter, altool; the key path is never shown', () => { const t = { framework: 'capacitor' as const, workspace: 'ios/App/App.xcworkspace', scheme: 'App', notes: [] }; const auth = { keyPath: '/tmp/k/AuthKey_X.p8', keyId: 'KEYID', issuerId: 'ISSUER' }; const a = archiveArgs({ target: t, root: '/p', configuration: 'Release', archivePath: '/p/build/ios/App.xcarchive', version: '1.4.0', build: '42', teamId: 'TEAMID1234', auth }); expect(a.slice(0, 3)).toEqual(['xcodebuild', '-workspace', '/p/ios/App/App.xcworkspace']); expect(a).toContain('-allowProvisioningUpdates'); expect(a.slice(-4)).toEqual(['archive', 'CURRENT_PROJECT_VERSION=42', 'MARKETING_VERSION=1.4.0', 'DEVELOPMENT_TEAM=TEAMID1234']); expect(showCmd(a, auth)).toContain('-authenticationKeyPath $ASC_P8'); expect(showCmd(a, auth)).not.toContain('/tmp/k'); expect(exportArgs('/a.xcarchive', '/E.plist', '/out', null)).toEqual(['xcodebuild', '-exportArchive', '-archivePath', '/a.xcarchive', '-exportOptionsPlist', '/E.plist', '-exportPath', '/out']); expect(flutterConfigArgs('42', '1.4.0')).toEqual(['flutter', 'build', 'ios', '--release', '--config-only', '--no-codesign', '--build-number', '42', '--build-name', '1.4.0']); expect(altoolArgs('/x.ipa', auth).slice(0, 4)).toEqual(['xcrun', 'altool', '--upload-package', '/x.ipa']); }); test('API build upload requests (Build Uploads)', () => { const r = buildUploadReqs('APPID', { bundleId: 'com.example.game', version: '1.4.0', build: '42' }, 'Example.ipa', 1048576 * 12); expect(r.upload.body).toEqual({ data: { type: 'buildUploads', attributes: { cfBundleShortVersionString: '1.4.0', cfBundleVersion: '42', platform: 'IOS' }, relationships: { app: { data: { type: 'apps', id: 'APPID' } } } } }); expect(r.file('U1').body.data.attributes).toEqual({ assetType: 'ASSET', fileName: 'Example.ipa', fileSize: 12582912, uti: 'com.apple.ipa' }); expect(r.commit('F1', 'abc').body.data.attributes).toEqual({ uploaded: true, sourceFileChecksums: { file: { hash: 'abc', algorithm: 'MD5' } } }); expect(uploadProblems({ errors: [{ code: 'ITMS-90062', message: 'version must be higher' }] }).errors).toEqual(['ITMS-90062: version must be higher']); }); }); describe('gradle projects', () => { test('React Native / Capacitor android/, Unity export, native root, Flutter', () => { const rn = dir('rn'); touch(rn, 'package.json', JSON.stringify({ dependencies: { 'react-native': '0.80.0' } })); touch(rn, 'android/settings.gradle'); touch(rn, 'android/gradlew', '#!/bin/sh'); touch(rn, 'android/app/build.gradle'); const t = pickGradle(rn); expect(t).toMatchObject({ framework: 'react-native', module: 'app', task: ':app:bundleRelease' }); expect(gradleArgs(t, { versionCode: 7, versionName: '1.2.0', hasWrapper: true }, 'linux')).toEqual(['./gradlew', ':app:bundleRelease', '--no-daemon', '--console=plain', '-Pandroid.injected.version.code=7', '-PversionCode=7', '-Pandroid.injected.version.name=1.2.0', '-PversionName=1.2.0']); // Windows: a bare gradlew.bat is looked up on PATH only; cmd /c runs the one in the project folder expect(gradleArgs(t, { hasWrapper: true }, 'win32').slice(0, 4)).toEqual(['cmd', '/c', 'gradlew.bat', ':app:bundleRelease']); expect(pickGradle(rn, { flavor: 'prod' }).task).toBe(':app:bundleProdRelease'); const unity = dir('unity'); touch(unity, 'Export/settings.gradle'); touch(unity, 'Export/launcher/build.gradle'); touch(unity, 'Export/unityLibrary/build.gradle'); expect(pickGradle(unity, { dir: 'Export' })).toMatchObject({ framework: 'unity', module: 'launcher', task: ':launcher:bundleRelease' }); const native = dir('native'); touch(native, 'settings.gradle.kts'); touch(native, 'app/build.gradle.kts'); expect(pickGradle(native).notes.join()).toContain('no Gradle wrapper'); const fl = dir('flutter'); touch(fl, 'pubspec.yaml', 'name: example'); touch(fl, 'android/settings.gradle'); touch(fl, 'android/app/build.gradle'); const ft = pickGradle(fl); expect(gradleArgs(ft, { versionCode: 9, hasWrapper: false })).toEqual(['flutter', 'build', 'appbundle', '--release', '--build-number', '9']); expect(() => pickGradle(dir('empty'))).toThrow('no Gradle project'); expect(() => pickGradle(native, { module: 'mobile' })).toThrow('module "mobile" not found'); }); test('signing goes through the environment, never argv', () => { const env = signingEnv({ storeFile: '/k/upload.jks', storePassword: 'pw1', keyAlias: 'upload', keyPassword: 'pw2' }); expect(env['ORG_GRADLE_PROJECT_android.injected.signing.store.password']).toBe('pw1'); expect(env['ORG_GRADLE_PROJECT_android.injected.signing.key.alias']).toBe('upload'); expect(signingEnv(null)).toEqual({}); }); test('findBundles: only new AABs, newest first', () => { const d = dir('out'); touch(d, 'app/build/outputs/bundle/release/app-release.aab', 'x'); touch(d, 'app/build/outputs/bundle/release/old.aab', 'x'); const past = new Date(Date.now() - 3_600_000); utimesSync(join(d, 'app/build/outputs/bundle/release/old.aab'), past, past); expect(findBundles([join(d, 'app/build/outputs/bundle')], Date.now() - 60_000).map(f => f.split('/').pop())).toEqual(['app-release.aab']); }); }); // play build passes -PversionCode / -PversionName; seen live (2026-10-12): a Capacitor app/build.gradle read versionCode // from an env var, so the AAB stayed at 1. Checked with AGP 8.13 the same day: android.injected.version.code does not // override a literal for bundleRelease; the Groovy and Kotlin lines below give the passed values (and the default without). describe('versionCode / versionName source in the build file', () => { const gv = (body: string) => `plugins { id 'com.android.application' }\nandroid {\n namespace 'com.example.game'\n defaultConfig {\n applicationId 'com.example.game' // don't touch\n${body}\n }\n}\n`; const kv = (body: string) => `plugins { id("com.android.application") }\nandroid {\n namespace = "com.example.game"\n defaultConfig {\n applicationId = "com.example.game"\n${body}\n }\n}\n`; const kinds = (file: string, src: string) => versionSourceIssues(file, src).map(i => [i.field, i.kind, i.line, i.current, i.fix]); test('Groovy: literal and env var warn with the exact line; findProperty does not', () => { expect(kinds('app/build.gradle', gv(" versionCode 3\n versionName '1.4.0'"))).toEqual([ ['versionCode', 'literal', 6, '3', "versionCode((project.findProperty('versionCode') ?: 3) as Integer)"], ['versionName', 'literal', 7, '1.4.0', "versionName project.findProperty('versionName') ?: '1.4.0'"]]); expect(kinds('app/build.gradle', gv(" versionCode System.getenv('VERSION_CODE') ? System.getenv('VERSION_CODE').toInteger() : 1\n versionName System.getenv('VERSION_NAME') ?: \"1.0.2\"")).map(k => k.slice(0, 4))).toEqual([ ['versionCode', 'other', 6, '1'], ['versionName', 'other', 7, '1.0.2']]); expect(kinds('app/build.gradle', gv(" versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'"))).toEqual([]); expect(kinds('app/build.gradle', gv(" versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 1\n versionName project.findProperty('versionName') ?: '1.0'"))).toEqual([]); // commented out lines do not count; nothing set at all is said too expect(kinds('app/build.gradle', gv(" // versionCode 1\n /* versionName '1.0' */")).map(k => [k[0], k[1]])).toEqual([['versionCode', 'missing'], ['versionName', 'missing']]); }); test('a variable read from the property first, then used, is not flagged (STAR DASH pattern); an env-only variable is', () => { const top = (def: string, body: string) => `plugins { id 'com.android.application' }\n${def}\nandroid {\n defaultConfig {\n${body}\n }\n}\n`; const ok = top("def erVersionCode = (project.findProperty('versionCode') ?: System.getenv('ER_ANDROID_VERSION_CODE') ?: '1').toString().toInteger()\ndef erVersionName = project.findProperty('versionName') ?: '1.0.0'", ' versionCode erVersionCode\n versionName erVersionName'); expect(kinds('app/build.gradle', ok)).toEqual([]); expect(kinds('app/build.gradle', top("ext.vc = (project.findProperty('versionCode') ?: 1) as Integer\next.vn = project.findProperty('versionName') ?: '1.0'", ' versionCode vc\n versionName vn'))).toEqual([]); expect(kinds('app/build.gradle.kts', `val vc = (project.findProperty("versionCode") as String?)?.toInt() ?: 1\nval vn: String = project.findProperty("versionName") as String? ?: "1.0"\nandroid {\n defaultConfig {\n versionCode = vc\n versionName = vn\n }\n}\n`)).toEqual([]); const env = top("def code = (System.getenv('VERSION_CODE') ?: '1').toInteger()", " versionCode code\n versionName project.findProperty('versionName') ?: '1.0'"); expect(kinds('app/build.gradle', env).map(k => [k[0], k[1]])).toEqual([['versionCode', 'other']]); }); test('Kotlin: literal and env var warn with the exact line; findProperty does not', () => { expect(kinds('app/build.gradle.kts', kv(' versionCode = 5\n versionName = "2.0"'))).toEqual([ ['versionCode', 'literal', 6, '5', 'versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 5'], ['versionName', 'literal', 7, '2.0', 'versionName = project.findProperty("versionName") as String? ?: "2.0"']]); expect(kinds('app/build.gradle.kts', kv(' versionCode = System.getenv("VERSION_CODE")?.toInt() ?: 1\n versionName = System.getenv("VERSION_NAME") ?: "1.0"')).map(k => k.slice(0, 4))).toEqual([ ['versionCode', 'other', 6, '1'], ['versionName', 'other', 7, '1.0']]); expect(kinds('app/build.gradle.kts', kv(' versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1\n versionName = project.findProperty("versionName") as String? ?: "1.0"'))).toEqual([]); }); test('the message names the file and line, says the injected property does not help, and gives the line', () => { const [i] = versionSourceIssues('app/build.gradle', gv(" versionCode System.getenv('VERSION_CODE') as Integer\n versionName project.findProperty('versionName') ?: '1.0'")); const m = versionSourceMessage(i!, 'android/app/build.gradle', 'capacitor'); expect(m).toContain('android/app/build.gradle:6: versionCode does not come from the project property versionCode'); expect(m).toContain('-PversionCode from play build is ignored'); expect(m).toContain('android.injected.version.* does not override it for bundleRelease'); expect(m).toContain("Use in defaultConfig: versionCode((project.findProperty('versionCode') ?: 1) as Integer)"); const d = dir('gradle-kts'); touch(d, 'app/build.gradle.kts', kv(' versionCode = 1')); expect(appBuildFile({ dir: d, module: 'app' })).toBe(join(d, 'app/build.gradle.kts')); expect(appBuildFile({ dir: d, module: 'other' })).toBeNull(); }); }); describe('credentials for builds', () => { test('android upload key from .storebound/credentials.json; inline ASC key goes to a private temp file', () => { const d = dir('creds'); touch(d, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'pw', ANDROID_KEY_ALIAS: 'upload' })); const saved = { ...process.env }; try { delete process.env.ANDROID_KEYSTORE; expect(androidSigning(d)).toEqual({ storeFile: join(d, 'keys/upload.jks'), storePassword: 'pw', keyAlias: 'upload', keyPassword: 'pw' }); expect(androidSigning(dir('nocreds'))).toBeNull(); // a Windows absolute path is used as it is, not joined onto the project folder if (process.platform === 'win32') { const w = dir('wincreds'); touch(w, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'C:/keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'pw', ANDROID_KEY_ALIAS: 'upload' })); expect(androidSigning(w)?.storeFile).toBe('C:/keys/upload.jks'); } // another machine's paths and a keystore the stored password does not open are named by field const m = dir('moved'); touch(m, 'keys/upload.jks', 'x'); touch(m, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'wrong', ANDROID_KEY_ALIAS: 'upload', PLAY_SERVICE_ACCOUNT: '/Users/someone/keys/play.json' })); const issues = credentialFileIssues(m, () => ({ ok: false, out: 'keytool error: java.io.IOException: Keystore was tampered with, or password was incorrect' })); expect(issues.some(i => i.startsWith('PLAY_SERVICE_ACCOUNT: /Users/someone/keys/play.json does not exist'))).toBe(true); expect(issues.some(i => i.startsWith('ANDROID_KEYSTORE_PASSWORD does not open'))).toBe(true); expect(credentialFileIssues(m, () => ({ ok: true, out: '' })).some(i => i.startsWith('ANDROID'))).toBe(false); const { pem } = newSigningKey(); const p8 = createPrivateKey(pem).export({ type: 'pkcs8', format: 'pem' }).toString(); Object.assign(process.env, { ASC_KEY_ID: 'KEYID00001', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: p8 }); const k = ascKeyFile(d); expect(readFileSync(k.keyPath, 'utf8')).toBe(p8); expect(k.keyPath.split(/[\\/]/).pop()).toBe(['AuthKey', 'KEYID00001.p8'].join('_')); k.cleanup(); expect(() => readFileSync(k.keyPath)).toThrow(); } finally { for (const key of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } }); }); describe('CLI dry runs (no store access)', () => { const cli = (cwd: string, args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', HOME: dir('clihome') } }); const project = (name: string): string => { const d = dir(name); touch(d, 'storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' } };\n`); touch(d, 'capacitor.config.ts', 'export default {}'); touch(d, 'ios/App/App.xcodeproj/project.pbxproj'); touch(d, 'android/settings.gradle'); touch(d, 'android/gradlew', '#!/bin/sh'); touch(d, 'android/app/build.gradle'); return d; }; test('apple build --dry --json: plan with ExportOptions and the commands', () => { const r = cli(project('cli-apple'), ['apple', 'build', '--dry', '--json', '--build-number', '42', '--version', '1.4.0']); expect(r.exitCode).toBe(0); const j = JSON.parse(r.stdout.toString()); expect(j).toMatchObject({ framework: 'capacitor', project: 'ios/App/App.xcodeproj', scheme: 'App', build: '42', teamId: 'TEAMID1234', signingStyle: 'automatic' }); expect(j.exportOptions.method).toBe('app-store-connect'); expect(j.steps[0]).toContain('CURRENT_PROJECT_VERSION=42'); expect(j.steps[0]).toContain('-authenticationKeyPath $ASC_P8'); }); test('play build --dry --json: task, versionCode, signing source', () => { const r = cli(project('cli-play'), ['play', 'build', '--dry', '--json', '--version-code', '12']); expect(r.exitCode).toBe(0); const j = JSON.parse(r.stdout.toString()); expect(j).toMatchObject({ framework: 'capacitor', dir: 'android', task: ':app:bundleRelease', versionCode: 12, signing: "the project's own signingConfig" }); expect(j.command).toContain('-Pandroid.injected.version.code=12'); }); test('play build: the Android SDK goes to Gradle through the environment; without one it stops before Gradle', () => { const d = project('cli-play-sdk'); const marker = join(d, 'gradle-env.txt'); touch(d, 'android/gradlew', `#!/bin/sh\necho "$ANDROID_HOME|$ANDROID_SDK_ROOT" > "${marker}"\nexit 3\n`); chmodSync(join(d, 'android/gradlew'), 0o755); const env = { ANDROID_HOME: '', ANDROID_SDK_ROOT: '', ANDROID_KEYSTORE: '' }; const run = () => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'play', 'build', '--version-code', '12', '--no-prebuild', '--allow-stale-web'], { cwd: d, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', HOME: dir('clihome-sdk'), ...env } }); let r = run(); expect(r.exitCode).toBe(1); expect(r.stderr.toString()).toContain('no Android SDK found'); expect(r.stderr.toString()).toContain('android/local.properties'); expect(existsSync(marker)).toBe(false); // sdk.dir in the (gitignored) local.properties is found and handed over; the file is never written const sdk = dir('sdk-folder'); mkdirSync(join(sdk, 'platform-tools'), { recursive: true }); const props = `sdk.dir=${sdk}\n`; touch(d, 'android/local.properties', props); r = run(); expect(r.stdout.toString()).toContain(`Android SDK: ${sdk} (from local.properties)`); expect(readFileSync(marker, 'utf8').trim()).toBe(`${sdk}|${sdk}`); expect(readFileSync(join(d, 'android/local.properties'), 'utf8')).toBe(props); }); test('play build --dry: a build file that does not read -PversionCode is warned about before Gradle, with the line to use', () => { const d = project('cli-play-vc'); touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode System.getenv('VERSION_CODE') ? System.getenv('VERSION_CODE').toInteger() : 1\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n"); let r = cli(d, ['play', 'build', '--dry', '--json', '--version-code', '12']); expect(r.exitCode).toBe(0); const j = JSON.parse(r.stdout.toString()); expect(j.versionWarnings).toHaveLength(1); expect(j.versionWarnings[0]).toContain("android/app/build.gradle:3: versionCode does not come from the project property versionCode"); expect(j.versionWarnings[0]).toContain("versionCode((project.findProperty('versionCode') ?: 1) as Integer)"); r = cli(d, ['play', 'build', '--dry', '--version-code', '12']); expect(r.stderr.toString()).toContain('-PversionCode from play build is ignored'); touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n"); r = cli(d, ['play', 'build', '--dry', '--json', '--version-code', '12']); expect(JSON.parse(r.stdout.toString()).versionWarnings).toEqual([]); }); test('play build: an AAB with another versionCode than planned is a hard stop, not copied', () => { const d = project('cli-play-stop'); touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode 1\n versionName '1.0'\n }\n}\n"); const built = join(d, 'prebuilt.aab'); writeFileSync(built, makeAab('com.example.game', 1, '1.0')); touch(d, 'android/gradlew', `#!/bin/sh\nmkdir -p app/build/outputs/bundle/release && cp "${built}" app/build/outputs/bundle/release/app-release.aab\n`); chmodSync(join(d, 'android/gradlew'), 0o755); const sdk = dir('sdk-folder-stop'); mkdirSync(join(sdk, 'platform-tools'), { recursive: true }); const r = Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'play', 'build', '--version-code', '12', '--no-prebuild', '--allow-stale-web'], { cwd: d, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', HOME: dir('clihome-stop'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: '', ANDROID_KEYSTORE: '' } }); expect(r.exitCode).toBe(1); expect(r.stderr.toString()).toContain('versionCode is a literal (versionCode 1)'); expect(r.stderr.toString()).toContain('the AAB has versionCode 1, not 12'); expect(r.stderr.toString()).toContain("Use in defaultConfig of android/app/build.gradle: versionCode((project.findProperty('versionCode') ?: 1) as Integer)"); expect(existsSync(join(d, 'build/android/com.example.game-1.aab'))).toBe(false); }); test('play upload refuses production', () => { const d = project('cli-up'); writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 3)); const r = cli(d, ['play', 'upload', 'app.aab', '--track', 'production', '--dry']); expect(r.exitCode).toBe(1); expect(r.stderr.toString()).toContain('testing tracks'); }); }); describe('MCP', () => { test('read-only tools only: signing status, originality; no build / upload / ensure', () => { const names = TOOLS.map(t => t.name); expect(names).toContain('apple_signing_status'); expect(names).toContain('originality'); for (const n of ['apple_signing_ensure', 'apple_build', 'apple_upload', 'play_build', 'play_upload']) expect(names).not.toContain(n); for (const t of TOOLS.filter(t => ['apple_signing_status', 'originality'].includes(t.name))) expect(t.write).toBe(false); }); }); describe('apple build outside the GUI session (pitfall 101)', () => { test('SSH or a non-Aqua launchd manager is named; the logged-in session passes', async () => { const { remoteBuildSession } = await import('../cli/lib/xcode'); expect(remoteBuildSession({ SSH_CONNECTION: '10.0.0.2 5000 10.0.0.3 22' }, 'Aqua')).toBe('this is an SSH session'); expect(remoteBuildSession({}, 'Background')).toContain('launchctl manager: Background'); expect(remoteBuildSession({}, 'Aqua')).toBeNull(); }); test('a project-relative ASC_P8 path becomes absolute (xcodebuild refuses a relative -authenticationKeyPath)', async () => { const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); const { join, isAbsolute } = await import('node:path'); const d = mkdtempSync(join(tmpdir(), 'sb-p8-')); mkdirSync(join(d, '.storebound')); const { generateKeyPairSync } = await import('node:crypto'); writeFileSync(join(d, '.storebound/AuthKey_KEYID00002.p8'), generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()); writeFileSync(join(d, '.storebound/credentials.json'), JSON.stringify({ ASC_KEY_ID: 'KEYID00002', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: '.storebound/AuthKey_KEYID00002.p8' })); const saved = { ...process.env }; try { for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) delete process.env[k]; const { ascKeyFile } = await import('../cli/lib/credentials'); const path = ascKeyFile(d).keyPath; expect(isAbsolute(path)).toBe(true); expect(path).toBe(join(d, '.storebound/AuthKey_KEYID00002.p8')); } finally { process.env = saved; } }); }); describe('storebound aab (CI reads the version code without uploading)', () => { test('prints packageName, versionCode, versionName as JSON', () => { const d = dir('inspect-aab'); writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 4321, '3.1.0')); const r = Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'aab', 'app.aab', '--json'], { cwd: d, stdout: 'pipe', stderr: 'pipe' }); expect(JSON.parse(r.stdout.toString())).toMatchObject({ packageName: 'com.example.game', versionCode: 4321, versionName: '3.1.0' }); }); });