CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m33s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
535 lines
18 KiB
JavaScript
535 lines
18 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { Buffer } from 'node:buffer';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
appleTeamId,
|
|
buildIos,
|
|
bundleIdentifier,
|
|
selectIdentity,
|
|
validateProfile,
|
|
} from '../build-ios.mjs';
|
|
|
|
const certificate = Buffer.from('synthetic test certificate');
|
|
const fingerprint = createHash('sha1')
|
|
.update(certificate)
|
|
.digest('hex')
|
|
.toUpperCase();
|
|
|
|
function profileFor(mode = 'testflight') {
|
|
return {
|
|
UUID: '00000000-1111-2222-3333-444444444444',
|
|
TeamIdentifier: [appleTeamId],
|
|
Platform: ['iOS'],
|
|
ExpirationDate: '2099-01-01T00:00:00Z',
|
|
DeveloperCertificates: [certificate.toString('base64')],
|
|
...(mode === 'development'
|
|
? { ProvisionedDevices: ['test-device'] }
|
|
: {}),
|
|
Entitlements: {
|
|
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
|
|
'com.apple.developer.team-identifier': appleTeamId,
|
|
'get-task-allow': mode === 'development',
|
|
'beta-reports-active': mode === 'testflight',
|
|
},
|
|
};
|
|
}
|
|
|
|
function fixture(t, mode = 'testflight', overrides = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const directory = join(root, 'repo/native');
|
|
const profilePath = join(root, 'profile.mobileprovision');
|
|
const config = {
|
|
productName: 'KanbanGit',
|
|
identifier: 'de.kanbangit.desktop',
|
|
version: '1.2.3',
|
|
};
|
|
const ios = {
|
|
identifier: bundleIdentifier,
|
|
bundle: {
|
|
iOS: {
|
|
developmentTeam: appleTeamId,
|
|
bundleVersion: '7',
|
|
minimumSystemVersion: '17.0',
|
|
},
|
|
},
|
|
};
|
|
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
|
|
writeFileSync(
|
|
join(directory, 'src-tauri/tauri.conf.json'),
|
|
JSON.stringify(config),
|
|
);
|
|
writeFileSync(
|
|
join(directory, 'src-tauri/tauri.ios.conf.json'),
|
|
JSON.stringify(ios),
|
|
);
|
|
writeFileSync(profilePath, 'external provisioning profile');
|
|
const profile = profileFor(mode);
|
|
const calls = [];
|
|
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
|
|
const home = join(root, 'home');
|
|
const installedProfile = join(
|
|
home,
|
|
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
|
|
);
|
|
const info = {
|
|
CFBundleIdentifier: bundleIdentifier,
|
|
CFBundleShortVersionString: '1.2.3',
|
|
CFBundleVersion: '7',
|
|
};
|
|
function app(path) {
|
|
mkdirSync(path, { recursive: true });
|
|
writeFileSync(
|
|
join(path, 'Info.plist'),
|
|
JSON.stringify({ ...info, ...overrides.info }),
|
|
);
|
|
}
|
|
const options = {
|
|
directory,
|
|
home,
|
|
mode,
|
|
platform: 'darwin',
|
|
log() {},
|
|
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
|
|
run(command, args, settings) {
|
|
calls.push({ command, args, settings });
|
|
if (overrides.failStep === settings.step) {
|
|
throw new Error('Simulated build failure');
|
|
}
|
|
if (command === 'python3') {
|
|
return settings.input.toString();
|
|
}
|
|
if (command === 'security') {
|
|
if (args[0] === 'cms') {
|
|
const embedded = args
|
|
.at(-1)
|
|
.endsWith('embedded.mobileprovision');
|
|
return JSON.stringify(
|
|
embedded && overrides.embeddedProfile
|
|
? overrides.embeddedProfile
|
|
: profile,
|
|
);
|
|
}
|
|
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
|
|
}
|
|
if (command === 'rustup') {
|
|
if (args[0] === 'target') {
|
|
return (
|
|
overrides.targets ??
|
|
'aarch64-apple-ios\naarch64-apple-ios-sim'
|
|
);
|
|
}
|
|
return `/permanent/toolchain/bin/${args[1]}`;
|
|
}
|
|
if (command === '/permanent/toolchain/bin/cargo') {
|
|
const library = join(
|
|
directory,
|
|
'src-tauri/target',
|
|
args[args.indexOf('--target') + 1],
|
|
args.includes('--release') ? 'release' : 'debug',
|
|
'libkanbangit_native.a',
|
|
);
|
|
mkdirSync(dirname(library), { recursive: true });
|
|
writeFileSync(library, 'compiled library');
|
|
}
|
|
if (command === 'xcodebuild' && args.includes('archive')) {
|
|
assert.ok(mode === 'simulator' || existsSync(installedProfile));
|
|
app(
|
|
join(
|
|
args[args.indexOf('-archivePath') + 1],
|
|
'Products/Applications/KanbanGit.app',
|
|
),
|
|
);
|
|
}
|
|
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
|
|
const output = args[args.indexOf('-exportPath') + 1];
|
|
mkdirSync(output, { recursive: true });
|
|
if (!overrides.missingIpa) {
|
|
writeFileSync(
|
|
join(output, 'KanbanGit.ipa'),
|
|
'signed ipa bytes',
|
|
);
|
|
}
|
|
}
|
|
if (command === 'ditto') {
|
|
app(join(args.at(-1), 'Payload/KanbanGit.app'));
|
|
}
|
|
if (command === 'xcrun') {
|
|
return '/permanent/Xcode/SDK';
|
|
}
|
|
return '';
|
|
},
|
|
};
|
|
return {
|
|
options,
|
|
calls,
|
|
profile,
|
|
profilePath,
|
|
destination,
|
|
installedProfile,
|
|
ios,
|
|
};
|
|
}
|
|
|
|
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
|
|
const f = fixture(t);
|
|
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
|
|
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
|
|
assert.equal(buildIos(f.options), f.destination);
|
|
const options = JSON.parse(
|
|
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
|
);
|
|
assert.equal(options.method, 'app-store-connect');
|
|
assert.equal(options.destination, 'export');
|
|
assert.equal(options.signingStyle, 'manual');
|
|
assert.equal(options.manageAppVersionAndBuildNumber, false);
|
|
assert.equal(options.signingCertificate, fingerprint);
|
|
assert.deepEqual(options.provisioningProfiles, {
|
|
[bundleIdentifier]: f.profile.UUID,
|
|
});
|
|
assert.equal(
|
|
readFileSync(
|
|
join(f.destination, 'export/KanbanGit.ipa.sha256'),
|
|
'utf8',
|
|
),
|
|
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
|
|
);
|
|
assert.equal(
|
|
f.calls.filter(
|
|
(call) =>
|
|
call.command === 'codesign' && call.args.includes('--verify'),
|
|
).length,
|
|
2,
|
|
);
|
|
assert.equal(existsSync(f.installedProfile), false);
|
|
const verification = f.calls.find((call) => call.command === 'codesign');
|
|
const requirement = verification.args[verification.args.indexOf('-R') + 1];
|
|
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
|
|
assert.ok(
|
|
requirement.includes('entitlement["beta-reports-active"] exists'),
|
|
);
|
|
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
|
|
for (const call of f.calls) {
|
|
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
|
|
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
|
|
assert.ok(
|
|
!call.args.some((arg) =>
|
|
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
|
|
arg,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
const archive = f.calls.find((call) => call.args.includes('archive'));
|
|
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
|
|
assert.ok(
|
|
archive.args.includes(
|
|
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
|
|
),
|
|
);
|
|
assert.ok(
|
|
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
|
|
'/aarch64-apple-ios/release/libkanbangit_native.a',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
|
|
const f = fixture(t, 'simulator');
|
|
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
|
buildIos(f.options);
|
|
assert.ok(
|
|
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
|
|
);
|
|
assert.ok(
|
|
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
|
|
);
|
|
assert.ok(
|
|
!f.calls.some(
|
|
(call) =>
|
|
['security', 'codesign'].includes(call.command) ||
|
|
call.args.includes('-exportArchive'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('local device development uses a registered-device development profile', (t) => {
|
|
const f = fixture(t, 'development');
|
|
buildIos(f.options);
|
|
const options = JSON.parse(
|
|
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
|
|
);
|
|
assert.equal(options.method, 'debugging');
|
|
assert.equal(options.destination, 'export');
|
|
});
|
|
|
|
for (const [name, change, message] of [
|
|
[
|
|
'different team',
|
|
(p) => {
|
|
p.TeamIdentifier = ['OLDTEAM'];
|
|
},
|
|
/explicitly match/,
|
|
],
|
|
[
|
|
'wildcard identifier',
|
|
(p) => {
|
|
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
|
|
},
|
|
/explicitly match/,
|
|
],
|
|
[
|
|
'macOS identifier',
|
|
(p) => {
|
|
p.Entitlements['application-identifier'] =
|
|
`${appleTeamId}.de.kanbangit.desktop`;
|
|
},
|
|
/explicitly match/,
|
|
],
|
|
[
|
|
'expired profile',
|
|
(p) => {
|
|
p.ExpirationDate = '2000-01-01';
|
|
},
|
|
/expired/,
|
|
],
|
|
[
|
|
'invalid expiry',
|
|
(p) => {
|
|
p.ExpirationDate = 'invalid';
|
|
},
|
|
/expired/,
|
|
],
|
|
[
|
|
'invalid UUID',
|
|
(p) => {
|
|
p.UUID = '../unexpected';
|
|
},
|
|
/invalid/,
|
|
],
|
|
[
|
|
'missing certificate',
|
|
(p) => {
|
|
p.DeveloperCertificates = [];
|
|
},
|
|
/invalid/,
|
|
],
|
|
[
|
|
'wrong platform',
|
|
(p) => {
|
|
p.Platform = ['OSX'];
|
|
},
|
|
/iOS profile/,
|
|
],
|
|
[
|
|
'Ad Hoc profile',
|
|
(p) => {
|
|
p.ProvisionedDevices = ['device'];
|
|
},
|
|
/App Store Connect/,
|
|
],
|
|
[
|
|
'enterprise profile',
|
|
(p) => {
|
|
p.ProvisionsAllDevices = true;
|
|
},
|
|
/App Store Connect/,
|
|
],
|
|
[
|
|
'development entitlement',
|
|
(p) => {
|
|
p.Entitlements['get-task-allow'] = true;
|
|
},
|
|
/App Store Connect/,
|
|
],
|
|
[
|
|
'missing beta entitlement',
|
|
(p) => {
|
|
delete p.Entitlements['beta-reports-active'];
|
|
},
|
|
/App Store Connect/,
|
|
],
|
|
]) {
|
|
test(`rejects ${name} before signing`, () => {
|
|
const profile = profileFor();
|
|
change(profile);
|
|
assert.throws(() => validateProfile(profile, 'testflight'), message);
|
|
});
|
|
}
|
|
|
|
test('a distribution profile cannot be used for device development', () => {
|
|
assert.throws(
|
|
() => validateProfile(profileFor(), 'development'),
|
|
/App Development/,
|
|
);
|
|
});
|
|
|
|
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
|
|
const profile = profileFor();
|
|
for (const identities of [
|
|
'0 valid identities found',
|
|
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
|
|
`1) ${fingerprint} "Developer ID Application: Example"`,
|
|
`1) ${fingerprint} "Apple Development: Example"`,
|
|
]) {
|
|
assert.throws(
|
|
() => selectIdentity(profile, identities, 'testflight'),
|
|
/private key/,
|
|
);
|
|
}
|
|
});
|
|
|
|
for (const step of [
|
|
'Generating the Xcode project',
|
|
'Building the iOS Rust library',
|
|
'Archiving locally without Apple provisioning updates',
|
|
'Verifying the Apple signature and team',
|
|
'Exporting the signed IPA locally (no upload)',
|
|
]) {
|
|
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
|
|
const f = fixture(t, 'testflight', { failStep: step });
|
|
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
|
assert.equal(existsSync(f.destination), false);
|
|
assert.equal(existsSync(f.installedProfile), false);
|
|
});
|
|
}
|
|
|
|
test('an existing identical local profile is preserved after the build', (t) => {
|
|
const f = fixture(t);
|
|
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
|
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
|
|
buildIos(f.options);
|
|
assert.equal(existsSync(f.installedProfile), true);
|
|
});
|
|
|
|
test('a conflicting local profile is never overwritten or deleted', (t) => {
|
|
const f = fixture(t);
|
|
mkdirSync(dirname(f.installedProfile), { recursive: true });
|
|
writeFileSync(f.installedProfile, 'user profile');
|
|
assert.throws(() => buildIos(f.options), /same UUID/);
|
|
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
|
|
});
|
|
|
|
test('missing toolchain targets fail before compilation without installing anything', (t) => {
|
|
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
|
|
assert.throws(
|
|
() => buildIos(f.options),
|
|
/rustup target add aarch64-apple-ios/,
|
|
);
|
|
assert.ok(
|
|
!f.calls.some(
|
|
(call) => call.args.includes('build') || call.args.includes('add'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('missing profile and ambient team overrides fail clearly', (t) => {
|
|
const f = fixture(t);
|
|
delete f.options.env.IOS_PROVISIONING_PROFILE;
|
|
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
|
|
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
|
|
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
|
|
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
|
|
f.options.env.TAURI_CONFIG = '{}';
|
|
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
|
|
});
|
|
|
|
for (const [name, overrides, message] of [
|
|
[
|
|
'wrong app version',
|
|
{ info: { CFBundleVersion: '8' } },
|
|
/unexpected identifier, version/,
|
|
],
|
|
[
|
|
'changed embedded profile',
|
|
{
|
|
embeddedProfile: {
|
|
...profileFor(),
|
|
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
},
|
|
},
|
|
/unexpected provisioning profile/,
|
|
],
|
|
['missing IPA', { missingIpa: true }, /did not produce/],
|
|
]) {
|
|
test(`rejects ${name} in the build output`, (t) => {
|
|
const f = fixture(t, 'testflight', overrides);
|
|
assert.throws(() => buildIos(f.options), message);
|
|
assert.equal(existsSync(f.destination), false);
|
|
});
|
|
}
|
|
|
|
test('completed artifacts are not silently overwritten', (t) => {
|
|
const f = fixture(t);
|
|
buildIos(f.options);
|
|
assert.throws(() => buildIos(f.options), /already exists/);
|
|
});
|
|
|
|
for (const mode of ['simulator', 'development']) {
|
|
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
|
|
const overrides = {};
|
|
const f = fixture(t, mode, overrides);
|
|
buildIos(f.options);
|
|
const previousBuild = join(f.destination, 'previous-build');
|
|
writeFileSync(previousBuild, 'verified artifact');
|
|
overrides.failStep = 'Building the iOS Rust library';
|
|
assert.throws(() => buildIos(f.options), /Simulated build failure/);
|
|
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
|
|
delete overrides.failStep;
|
|
buildIos(f.options);
|
|
assert.equal(existsSync(previousBuild), false);
|
|
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
|
|
assert.equal(existsSync(f.installedProfile), false);
|
|
});
|
|
}
|
|
|
|
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
|
|
const assets = fileURLToPath(
|
|
new URL(
|
|
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
|
|
import.meta.url,
|
|
),
|
|
);
|
|
const catalog = JSON.parse(
|
|
readFileSync(join(assets, 'Contents.json'), 'utf8'),
|
|
);
|
|
assert.ok(
|
|
catalog.images.some(
|
|
(icon) =>
|
|
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
|
|
),
|
|
);
|
|
for (const icon of catalog.images) {
|
|
const png = readFileSync(join(assets, icon.filename));
|
|
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
|
const [width, height] = icon.size.split('x').map(Number);
|
|
const scale = Number.parseInt(icon.scale, 10);
|
|
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
|
|
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
|
|
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
|
|
for (
|
|
let offset = 8;
|
|
offset < png.length;
|
|
offset += png.readUInt32BE(offset) + 12
|
|
) {
|
|
assert.notEqual(
|
|
png.subarray(offset + 4, offset + 8).toString(),
|
|
'tRNS',
|
|
icon.filename,
|
|
);
|
|
}
|
|
}
|
|
});
|