storebound/tests/desktop-release.test.ts

154 lines
11 KiB
TypeScript

// tools/desktop-release.ts and tools/desktop-version.ts: the pure parts of the desktop release pipeline.
import { describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems, releaseTogetherProblems, forgejoMacFiles } from '../tools/desktop-release';
import { COPIES, SEMVER, setVersion, versions } from '../tools/desktop-version';
describe('desktop version authority', () => {
test('the repository has one version: the CLI, the plugin manifests and the desktop app', () => {
const { version, drift } = versions();
expect(version).toMatch(SEMVER); // x.y.z, or a pre-release such as 0.0.0-rehearsal.1 (tools/release.ts --rehearsal)
expect(drift).toEqual([]);
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '../package.json'), 'utf8'));
expect(version).toBe(pkg.version);
expect(pkg.desktopVersion).toBeUndefined();
expect(COPIES.map(c => c.file)).toContain('apps/desktop/src-tauri/tauri.conf.json');
expect(COPIES[0]!.file).toBe('package.json');
});
test('drift is found and --set fixes every copy', () => {
const root = mkdtempSync(join(tmpdir(), 'sb-ver-'));
for (const c of COPIES) { mkdirSync(join(root, c.file, '..'), { recursive: true }); cpSync(join(import.meta.dir, '..', c.file), join(root, c.file)); }
const conf = join(root, 'apps/desktop/src-tauri/tauri.conf.json');
writeFileSync(conf, readFileSync(conf, 'utf8').replace(/"version": "[^"]+"/, '"version": "9.9.9"'));
expect(versions(root).drift.map(d => d.file)).toEqual(['apps/desktop/src-tauri/tauri.conf.json']);
setVersion('0.2.0-beta.1', root);
expect(versions(root)).toEqual({ version: '0.2.0-beta.1', drift: [] });
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version).toBe('0.2.0-beta.1');
expect(JSON.parse(readFileSync(join(root, '.claude-plugin/marketplace.json'), 'utf8')).plugins[0].version).toBe('0.2.0-beta.1');
// only the package's own version line of Cargo.toml / Cargo.lock changes
expect(readFileSync(join(root, 'apps/desktop/src-tauri/Cargo.toml'), 'utf8')).toContain('tauri = { version = "2.12"');
expect(() => setVersion('latest', root)).toThrow();
});
});
describe('desktop release', () => {
test('the app and the npm CLI are published with one version', () => {
expect(releaseTogetherProblems('0.8.0', '0.8.0')).toEqual([]);
expect(releaseTogetherProblems('0.1.0', '0.8.0')[0]).toMatch(/one version/);
expect(releaseTogetherProblems('0.8.0', null)).toHaveLength(1);
});
test('signing refuses SSH and non-GUI sessions', () => {
expect(remoteSessionReason({}, 'Aqua')).toBeNull();
expect(remoteSessionReason({ SSH_CONNECTION: '1 2 3 4' }, 'Aqua')).toMatch(/SSH/);
expect(remoteSessionReason({ SSH_TTY: '/dev/ttys001' }, 'Aqua')).toMatch(/SSH/);
expect(remoteSessionReason({}, 'Background')).toMatch(/GUI session/);
});
test('only a Developer ID Application identity signs a release', () => {
const out = ` 1) ${'A'.repeat(40)} "Apple Distribution: Example Studio (TEAM000001)"
2) ${'B'.repeat(40)} "Apple Development: Example Person (TEAM000002)"
2 valid identities found`;
expect(developerIdIdentity(out)).toBeNull();
expect(developerIdIdentity(`${out}\n 3) ${'C'.repeat(40)} "Developer ID Application: Example Studio (TEAM000001)"`)).toBe('Developer ID Application: Example Studio (TEAM000001)');
});
test('artifact names say when a build is unsigned', () => {
expect(artifactName('0.1.0', 'dmg', false)).toBe('storebound_0.1.0_universal-unsigned.dmg');
expect(artifactName('0.1.0', 'updater', true)).toBe('storebound_0.1.0_universal.app.tar.gz');
expect(artifactName('0.1.0', 'nsis', false)).toBe('storebound_0.1.0_x64-setup-unsigned.exe');
expect(artifactName('0.1.0', 'appimage', false)).toBe('storebound_0.1.0_amd64.AppImage');
expect(releaseUrl('0.1.0', 'a.dmg')).toBe('https://storebound.dev/desktop/dl/0.1.0/a.dmg');
});
test('path-free build: home, checkout, cargo, rustup and target dir are remapped, general first', () => {
const env = pathFreeEnv('/Users/you/src/storebound', { CARGO_TARGET_DIR: '/tmp/t', RUSTFLAGS: '-Cforce-frame-pointers=yes', CFLAGS: '-O2' }, '/Users/you');
const flags = env.CARGO_ENCODED_RUSTFLAGS!.split('\x1f');
expect(flags).toEqual([
'-Cforce-frame-pointers=yes',
'--remap-path-prefix=/Users/you=~',
'--remap-path-prefix=/Users/you/src/storebound=/src',
'--remap-path-prefix=/tmp/t=/target',
'--remap-path-prefix=/Users/you/.cargo=/cargo',
'--remap-path-prefix=/Users/you/.rustup=/rustup',
]);
expect(env.CFLAGS).toStartWith('-O2 -ffile-prefix-map=/Users/you=~');
expect(env.CXXFLAGS).toContain('-ffile-prefix-map=/Users/you/.rustup=/rustup');
const w = pathFreeEnv('C:\\src\\storebound', { CARGO_TARGET_DIR: 'C:\\t' }, 'C:\\Users\\you', 'win32');
const wf = w.CARGO_ENCODED_RUSTFLAGS!.split('\x1f');
expect(wf).toContain('--remap-path-prefix=C:\\Users\\you=~');
expect(wf).toContain('--remap-path-prefix=\\\\?\\C:\\Users\\you=~');
expect(wf).toContain('-Clink-arg=/PDBALTPATH:%_PDB%');
expect(w.CL).toContain('"/d1trimfile:C:\\Users\\you\\.rustup/"');
expect(w.LINK).toBe('/PDBALTPATH:%_PDB%');
expect(w.CFLAGS).toBeUndefined();
});
test('latest.json keeps the other platforms of the same version, a new version starts fresh', () => {
const mac = { platforms: { 'darwin-aarch64': { signature: 's', url: 'https://storebound.dev/desktop/dl/0.1.0/m.app.tar.gz' } } };
const win = { platforms: { 'windows-x86_64': { signature: 'w', url: 'https://storebound.dev/desktop/dl/0.1.0/w.exe' } } };
const one = mergeManifest(null, '0.1.0', 'first', '2026-10-11T00:00:00Z', mac);
const two = mergeManifest(one, '0.1.0', 'other notes', '2026-10-12T00:00:00Z', win);
expect(Object.keys(two.platforms)).toEqual(['darwin-aarch64', 'windows-x86_64']);
expect(two.notes).toBe('first');
expect(two.pub_date).toBe('2026-10-12T00:00:00Z');
expect(Object.keys(mergeManifest(two, '0.1.1', 'n', 'x', win).platforms)).toEqual(['windows-x86_64']);
});
test('SHA256SUMS and what is uploaded', () => {
expect(sumsText([{ file: 'b', sha256: '2' }, { file: 'a', sha256: '1' }])).toBe('1 a\n2 b\n');
expect(uploadable('storebound_0.1.0_universal-unsigned.dmg')).toBe(true);
expect(uploadable('latest.json')).toBe(true);
for (const n of ['evidence-macos-0.1.0.json', 'evidence-linux-0.1.0.json', '.DS_Store', 'latest.json.part', 'a b.dmg']) expect(uploadable(n)).toBe(false);
});
test('add: every artifact of a version comes from one commit', () => {
const a = 'a'.repeat(40), b = 'b'.repeat(40);
expect(commitProblems(a, { 'evidence-macos-0.1.0.json': a, 'evidence-linux-0.1.0.json': undefined })).toEqual([]);
expect(commitProblems(undefined, { 'evidence-macos-0.1.0.json': a })).toEqual([]);
expect(commitProblems(b, { 'evidence-macos-0.1.0.json': a })[0]).toContain('evidence-macos-0.1.0.json says commit aaaaaaaaaa');
expect(commitProblems('25258a8', {})[0]).toContain('40-character');
});
});
describe('universal slices (never run under Rosetta)', () => {
test('every executable must carry arm64 and x86_64', () => {
expect(sliceProblems([{ file: 'Contents/MacOS/storebound-desktop', archs: ['x86_64', 'arm64'] }, { file: 'Contents/MacOS/storebound', archs: ['arm64', 'x86_64'] }])).toEqual([]);
expect(sliceProblems([{ file: 'a', archs: ['x86_64'] }, { file: 'b', archs: ['arm64'] }, { file: 'c', archs: [] }])).toEqual(['a (x86_64)', 'b (arm64)', 'c (no slices)']);
});
test('the release tools never launch an x86_64 slice (Rosetta warns the owner about the app)', () => {
for (const f of ['tools/desktop-release.ts', 'tools/desktop-boot-check.ts', 'tools/build-sidecars.ts']) {
const src = readFileSync(join(import.meta.dir, '..', f), 'utf8');
expect(src).not.toMatch(/['"]arch['"]\s*,|arch -x86_64|`-\$\{arch\}`/);
}
});
});
describe('architecture guard (no Rosetta notice for users)', () => {
const ok = [{ file: 'Contents/MacOS/storebound-desktop', lipo: 'x86_64 arm64\n' }, { file: 'Contents/MacOS/storebound', lipo: 'x86_64 arm64' }, { file: 'Contents/Frameworks/libx.dylib', lipo: 'arm64' }];
test('every Mach-O with arm64 and a neutral Info.plist pass', () => {
expect(archProblems(ok, { CFBundleIdentifier: 'dev.example' })).toEqual([]);
expect(archProblems(ok, { LSRequiresNativeExecution: true, LSArchitecturePriority: ['arm64', 'x86_64'] })).toEqual([]);
});
test('an Intel-only helper, a file without slices or one without arm64 fail', () => {
expect(archProblems([...ok, { file: 'Contents/Resources/helper', lipo: 'x86_64' }], {})).toEqual(['Contents/Resources/helper: no arm64 slice (x86_64, Intel-only: needs Rosetta)']);
expect(archProblems([{ file: 'a', lipo: '' }], {})).toEqual(['a: no arm64 slice (no slices)']);
expect(archProblems([{ file: 'b', lipo: 'x86_64h i386' }], {})).toEqual(['b: no arm64 slice (x86_64h i386, Intel-only: needs Rosetta)']);
});
test('an Info.plist that forces Intel fails', () => {
expect(archProblems(ok, { LSRequiresNativeExecution: false })).toEqual(['Info.plist: LSRequiresNativeExecution is false']);
expect(archProblems(ok, { LSArchitecturePriority: ['x86_64', 'arm64'] })).toEqual(['Info.plist: LSArchitecturePriority puts x86_64 first']);
});
test('the release runs the guard before signing (tauri build --no-sign, then tauri bundle)', () => {
const src = readFileSync(join(import.meta.dir, '..', 'tools/desktop-release.ts'), 'utf8');
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("'tauri', 'bundle'");
expect(build).toBeGreaterThan(0);
expect(guard).toBeGreaterThan(build);
expect(bundle).toBeGreaterThan(guard);
});
});
describe('upload-forgejo (the Mac adds its files to the workflow release)', () => {
test('DMG and updater bundle with signatures, only of this version', () => {
const names = ['storebound_0.8.0_universal.dmg', 'storebound_0.8.0_universal.dmg.sig', 'storebound_0.8.0_universal.app.tar.gz',
'storebound_0.8.0_universal.app.tar.gz.sig', 'storebound_0.8.0_amd64.deb', 'latest.json', 'SHA256SUMS', 'evidence-mac-0.8.0.json',
'storebound_0.7.0_universal.dmg'];
expect(forgejoMacFiles(names, '0.8.0')).toEqual(['storebound_0.8.0_universal.app.tar.gz', 'storebound_0.8.0_universal.app.tar.gz.sig',
'storebound_0.8.0_universal.dmg', 'storebound_0.8.0_universal.dmg.sig']);
});
});