storebound/tests/build-sign.test.ts

577 lines
46 KiB
TypeScript

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 { notesGuard } from '../cli/play/build';
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, aabSignedEntries, appBuildFile, assertAabSigned, findBundles, gradleArgs, maskArgs, 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, ipaVersionProblem, 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 & <c>'], nested: { k: 'v' }, empty: {}, data: Buffer.from([0, 1, 2]) };
const text = toXmlPlist(v);
expect(text).toContain('<key>method</key>\n\t<string>app-store-connect</string>');
expect(text).toContain('<string>b &amp; &lt;c&gt;</string>');
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<string, unknown>;
const bin = parseBinaryPlist(readFileSync(join(FIX, 'Info.bplist'))) as Record<string, unknown>;
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['attributes']> = {}): 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('<key>signingStyle</key>\n\t<string>manual</string>');
});
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<string, unknown> = {}) => {
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>): 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' });
});
});
describe('signed AAB, masked signing, IPA version train, release notes', () => {
const s = { storeFile: '/k/upload.jks', storePassword: 'pw-store', keyAlias: 'upload', keyPassword: 'pw-key' };
const t = { framework: 'native' as const, dir: '/p', module: 'app', task: ':app:bundleRelease', notes: [] };
test('gradleArgs passes the injected signing properties, maskArgs hides the passwords', () => {
const argv = gradleArgs(t, { hasWrapper: true, signing: s }, 'linux');
expect(argv).toContain('-Pandroid.injected.signing.store.file=/k/upload.jks');
expect(argv).toContain('-Pandroid.injected.signing.key.alias=upload');
expect(argv.join(' ')).not.toContain('pw-store');
expect(argv.join(' ')).not.toContain('pw-key');
expect(argv.join(' ')).not.toContain('password');
const env = signingEnv(s);
expect(env['ORG_GRADLE_PROJECT_android.injected.signing.store.password']).toBe('pw-store');
expect(env['ORG_GRADLE_PROJECT_android.injected.signing.key.password']).toBe('pw-key');
const shown = maskArgs(argv).join(' ');
expect(shown).not.toContain('pw-store');
expect(shown).not.toContain('pw-key');
expect(shown).toContain('key.alias=upload');
expect(gradleArgs(t, { hasWrapper: true }, 'linux').join(' ')).not.toContain('signing');
});
test('an unsigned AAB is refused, a signed one passes', () => {
const unsigned = join(tmp, 'unsigned.aab'), signed = join(tmp, 'signed.aab');
writeFileSync(unsigned, makeAab('com.example.app', 3, '1.0.0', false));
writeFileSync(signed, makeZip([{ name: 'BundleConfig.pb', data: 'x' }, { name: 'META-INF/UPLOAD.SF', data: 'x' }, { name: 'META-INF/UPLOAD.RSA', data: 'x' }]));
expect(aabSignedEntries(unsigned)).toBe(false);
expect(() => assertAabSigned(unsigned, 'unsigned.aab', () => null)).toThrow('not signed');
expect(() => assertAabSigned(unsigned, 'unsigned.aab', () => null)).toThrow('ANDROID_KEYSTORE');
expect(() => assertAabSigned(signed, 'signed.aab', () => null)).not.toThrow();
expect(() => assertAabSigned(signed, 'signed.aab', () => ({ ok: false, out: 'jar is unsigned.' }))).toThrow('jarsigner -verify');
expect(() => assertAabSigned(signed, 'signed.aab', () => ({ ok: true, out: 'jar verified.' }))).not.toThrow();
});
test('the IPA version must be the version train being prepared', () => {
expect(ipaVersionProblem('1.0', '1.4.0', 'the editable App Store version')).toContain('--allow-version-mismatch');
expect(ipaVersionProblem('1.0', '1.4.0', 'x')).toContain('MARKETING_VERSION=1.4.0');
// Apple compares numerically: 1.0 and 1.0.0 are one train
expect(ipaVersionProblem('1.0.0', '1.0', 'x')).toBeNull();
expect(ipaVersionProblem('2.1', '2.1.0', 'x')).toBeNull();
expect(ipaVersionProblem('2.1.1', '2.1', 'x')).not.toBeNull();
expect(ipaVersionProblem('1.4.0', '1.4.0', 'x')).toBeNull();
expect(ipaVersionProblem('1.0', undefined, 'x')).toBeNull();
});
test('play build refuses release notes that name iOS before the build', () => {
const d = dir('notes');
writeFileSync(join(d, 'listing.md'), '## English\n<!-- play: en-US -->\n### Title\n```\nApp\n```\n### Short\n```\nShort text\n```\n### Full\n```\nFull text\n```\n### What\'s new\n```\nNow also on iPhone\n```\n');
const ctx = { args: { list: () => [] }, loaded: { root: d, resolve: (p: string) => join(d, p), config: { app: { name: 'App' }, play: { listing: 'listing.md' } } } } as never;
expect(() => notesGuard(ctx)).toThrow('iPhone');
writeFileSync(join(d, 'listing.md'), readFileSync(join(d, 'listing.md'), 'utf8').replace('Now also on iPhone', 'Faster start'));
expect(() => notesGuard(ctx)).not.toThrow();
});
});