124 lines
7.8 KiB
TypeScript
124 lines
7.8 KiB
TypeScript
// Pitfalls 53 + 54: a second `apple build` failed in exportArchive ("Copy failed": Homebrew rsync first on PATH, and
|
|
// the old App.ipa still in build/ios/export), then `apple upload` took the stale IPA (build 1, already VALID) and
|
|
// exited 0. Per-build export folders, the IDEDistribution log's real error, Apple's PATH first for xcodebuild, a loud
|
|
// upload failure for a build number App Store Connect already has, and rsync by absolute path in desktop publish.
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { exportDir, exportIpa } from '../cli/apple/build';
|
|
import { existingBuild, exportedIpas } from '../cli/apple/upload';
|
|
import { distributionErrors, foreignRsync, xcodePath } from '../cli/lib/xcode';
|
|
import { publishSteps } from '../tools/desktop-release';
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'sb-export-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
/** A fake xcodebuild -exportArchive: fails like Xcode ("Copy failed", exit 70) when the target IPA already exists. */
|
|
function fakeXcodebuild(dir: string, logs: string): string {
|
|
const f = join(dir, 'xcodebuild');
|
|
writeFileSync(f, `#!/bin/sh
|
|
out=""; while [ $# -gt 0 ]; do [ "$1" = "-exportPath" ] && out="$2"; shift; done
|
|
if [ -e "$out/App.ipa" ]; then
|
|
echo "error: exportArchive Copy failed" >&2
|
|
echo "IDEDistribution: -[IDEDistributionLogging _createLoggingBundleAtPath:]: Created bundle at path \\"${logs}\\"." >&2
|
|
exit 70
|
|
fi
|
|
mkdir -p "$out" && echo ipa > "$out/App.ipa" && echo "** EXPORT SUCCEEDED **"
|
|
`);
|
|
chmodSync(f, 0o755);
|
|
return f;
|
|
}
|
|
|
|
describe('apple build: export', () => {
|
|
test('each build exports into its own folder, cleaned first; a stale target makes the fake fail', async () => {
|
|
const out = join(tmp, 'build-ios'), logs = join(tmp, 'App.xcdistributionlogs');
|
|
mkdirSync(logs, { recursive: true });
|
|
writeFileSync(join(logs, 'IDEDistribution.standard.log'), 'step\nrsync error: syntax or usage error (code 1) at main.c(1795) [server=3.5.1]\n');
|
|
const xb = fakeXcodebuild(tmp, logs);
|
|
const argv = (p: string): string[] => [xb, '-exportArchive', '-exportPath', p];
|
|
expect(exportDir(out, '1.0.0', '2')).toBe(join(out, 'export', '1.0.0-2'));
|
|
expect(exportDir(out, undefined, '2')).toBe(join(out, 'export', 'build-2'));
|
|
const p1 = exportDir(out, '1.0.0', '1');
|
|
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p1, auth: null, argv: argv(p1) })).toBe(join(p1, 'App.ipa'));
|
|
// the same build again: the folder is cleaned first, so the fake (fails on an existing target) passes
|
|
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p1, auth: null, argv: argv(p1) })).toBe(join(p1, 'App.ipa'));
|
|
const p2 = exportDir(out, '1.0.0', '2');
|
|
expect(await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: p2, auth: null, argv: argv(p2) })).toBe(join(p2, 'App.ipa'));
|
|
expect(existsSync(join(p1, 'App.ipa'))).toBe(true);
|
|
// a stale target that the export does not clean (argv aimed at another folder): non-zero exit, real reason shown
|
|
const err = await exportIpa({ root: tmp, out, archivePath: 'a', exportPath: join(out, 'export', 'x'), auth: null, argv: argv(p1), tmp: join(tmp, 'none') }).then(() => new Error("no error"), (e: unknown) => e as Error);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.message).toContain('exit 70');
|
|
expect(err.message).toContain('rsync error: syntax or usage error');
|
|
expect(err.message).toContain('storebound doctor');
|
|
});
|
|
|
|
test('an export that exits 0 without an IPA fails', async () => {
|
|
const f = join(tmp, 'noipa');
|
|
writeFileSync(f, '#!/bin/sh\nexit 0\n');
|
|
chmodSync(f, 0o755);
|
|
const p = join(tmp, 'empty-export');
|
|
const err = await exportIpa({ root: tmp, out: tmp, archivePath: 'a', exportPath: p, auth: null, argv: [f] }).then(() => new Error("no error"), (e: unknown) => e as Error);
|
|
expect(err.message).toContain('left no .ipa');
|
|
});
|
|
|
|
test('distributionErrors: the folder named in the output, else the newest in the temp folder', () => {
|
|
const t = join(tmp, 'tmpdir'), d = join(t, 'X.xcdistributionlogs');
|
|
mkdirSync(d, { recursive: true });
|
|
writeFileSync(join(d, 'IDEDistribution.verbose.log'), 'ok\nerror: something broke\n');
|
|
expect(distributionErrors('', t)?.lines).toEqual(['error: something broke']);
|
|
expect(distributionErrors('', join(tmp, 'nothing'))).toBeNull();
|
|
});
|
|
|
|
test('xcodebuild PATH: Apple folders first; a Homebrew rsync is reported', () => {
|
|
expect(xcodePath('/opt/homebrew/bin:/usr/bin:/bin')).toBe('/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin');
|
|
expect(xcodePath('/x', '/Applications/Xcode.app/Contents/Developer').split(':')[0]).toBe('/Applications/Xcode.app/Contents/Developer/usr/bin');
|
|
const has = (set: string[]) => (p: string) => set.includes(p);
|
|
expect(foreignRsync('/opt/homebrew/bin:/usr/bin', has(['/opt/homebrew/bin/rsync', '/usr/bin/rsync']))).toBe('/opt/homebrew/bin/rsync');
|
|
expect(foreignRsync('/opt/homebrew/bin:/usr/bin', has(['/usr/bin/rsync']))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('apple upload: a build number App Store Connect already has', () => {
|
|
const base = { ipa: 'build/ios/export/App.ipa', build: '1', next: '3', allowExisting: false, uploadComplete: false, onAsc: false };
|
|
test('a stale IPA fails loudly (exit 1 via the thrown error)', () => {
|
|
const r = existingBuild({ ...base, onAsc: true, state: 'VALID' });
|
|
expect(r).toBeInstanceOf(Error);
|
|
expect((r as Error).message).toContain('is build 1, which is already on App Store Connect (VALID)');
|
|
expect((r as Error).message).toContain('Did the last build fail?');
|
|
expect(existingBuild({ ...base, uploadComplete: true })).toBeInstanceOf(Error);
|
|
});
|
|
test('a new build uploads; --allow-existing only re-checks', () => {
|
|
expect(existingBuild(base)).toBe('upload');
|
|
expect(existingBuild({ ...base, onAsc: true, allowExisting: true })).toBe('recheck');
|
|
expect(existingBuild({ ...base, uploadComplete: true, allowExisting: true })).toBe('recheck');
|
|
});
|
|
test('the default IPA search covers the per-build folders, newest first', () => {
|
|
const e = join(tmp, 'ups', 'export');
|
|
mkdirSync(join(e, '1.0.0-2'), { recursive: true });
|
|
writeFileSync(join(e, 'App.ipa'), 'old');
|
|
writeFileSync(join(e, '1.0.0-2', 'App.ipa'), 'new');
|
|
utimesSync(join(e, 'App.ipa'), new Date(2020, 0, 1), new Date(2020, 0, 1));
|
|
expect(exportedIpas(e)).toEqual([join(e, '1.0.0-2', 'App.ipa'), join(e, 'App.ipa')]);
|
|
expect(exportedIpas(join(tmp, 'missing'))).toEqual([]);
|
|
});
|
|
test('the release and beta flows pin the build and resume with --allow-existing', () => {
|
|
for (const f of ['templates/workflows/release.yml', 'templates/workflows/beta.yml', 'templates/profiles/app/workflows/release.yml', 'templates/profiles/app/workflows/beta.yml'])
|
|
expect(readFileSync(join(import.meta.dir, '..', f), 'utf8')).toContain('run: apple upload --no-wait --build ${BUILD} --allow-existing');
|
|
});
|
|
});
|
|
|
|
describe('desktop publish: rsync by absolute path', () => {
|
|
const o = { host: 'deploy@example.com', v: '0.8.1', channel: 'stable', out: '/o', files: ['latest.json'] };
|
|
test('Apple rsync without --chmod; modes set on the server', () => {
|
|
const s = publishSteps(o, p => p === '/usr/bin/rsync');
|
|
expect(s[1]!.slice(0, 3)).toEqual(['/usr/bin/rsync', '-a', '--delete']);
|
|
expect(s.flat().join(' ')).not.toContain('--chmod');
|
|
expect(s[2]![2]).toContain('chmod 644');
|
|
});
|
|
test('the Homebrew keg when there is no /usr/bin/rsync; none is an error', () => {
|
|
expect(publishSteps(o, p => p === '/opt/homebrew/opt/rsync/bin/rsync')[1]![0]).toBe('/opt/homebrew/opt/rsync/bin/rsync');
|
|
expect(() => publishSteps(o, () => false)).toThrow('publish needs rsync');
|
|
});
|
|
});
|