import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { prepareGalaxyBuild } from './prepare-galaxy-build.mjs'; const roots = []; afterEach(async () => { for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }); }); async function fixture() { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'galaxy-prep-test-')); roots.push(root); const state = { commit: 'a'.repeat(40), branch: 'main', status: '', rustc: 'rustc 1\nhost: aarch64-apple-darwin', cargo: 'cargo 1', bundle: 'cargo-bundle 1', clang: 'clang 1', sdkPath: '/sdk/one', sdkVersion: '14.0', sdkBuild: '22A', developerDir: '/Library/Developer/CommandLineTools', ignoredResources: '', builds: 0, fail: false, commands: [] }; const stateTarget = path.join(root, 'target'); const app = () => path.join(state.targetDir, 'aarch64-apple-darwin/release/bundle/osx/Galaxy.app'); state.targetDir = stateTarget; const run = async (name, args, { onLog, env } = {}) => { state.commands.push([name, ...args]); if (env && name === 'cargo' && args[0] === 'bundle' && args.includes('--release')) state.buildPath = env.PATH; const key = [name, ...args].join(' '); if (key === 'git status --porcelain --untracked-files=all') return state.status; if (key === 'git symbolic-ref --quiet --short HEAD') { if (!state.branch) throw new Error('detached HEAD'); return state.branch; } if (key.startsWith('git pull --ff-only origin ')) { onLog?.('Already up to date.'); return ''; } if (key === 'git rev-parse --verify HEAD') return state.commit; if (key === 'rustc --version --verbose') return state.rustc; if (key === 'cargo --version --verbose') return state.cargo; if (key === 'cargo bundle --version') return state.bundle; if (key === 'cargo metadata --format-version 1 --no-deps') return JSON.stringify({ target_directory: state.targetDir }); if (key === 'clang --version') return state.clang; if (key === 'xcrun --show-sdk-path') { if (state.failSdk) throw new Error('SDK probe failed'); return state.sdkPath; } if (key === 'xcrun --show-sdk-version') return state.sdkVersion; if (key === 'xcrun --show-sdk-build-version') return state.sdkBuild; if (key === 'xcode-select -p') return state.developerDir; if (key.startsWith('git ls-files --others --ignored --exclude-standard --')) return state.ignoredResources; if (key.startsWith('cargo bundle --release ')) { state.builds++; if (state.changeCommitOnBuild) state.commit = 'c'.repeat(40); if (state.fail) throw new Error('bundle failed'); await fs.mkdir(path.join(app(), 'Contents/MacOS'), { recursive: true }); await fs.writeFile(path.join(app(), 'Contents/MacOS/galaxy-oss'), `built ${state.builds}`); await fs.chmod(path.join(app(), 'Contents/MacOS/galaxy-oss'), 0o755); await fs.symlink('MacOS/galaxy-oss', path.join(app(), 'Contents/link')); return ''; } throw new Error(`Unexpected command: ${key}`); }; const options = { run, env: { PATH: '/usr/bin' }, home: path.join(root, 'home'), platform: 'darwin', arch: 'arm64' }; const events = []; const observer = { updateStep: (index, event) => events.push([index, event]), appendLog: (index, line) => events.push([index, line]) }; async function prepare() { return prepareGalaxyBuild(root, observer, options); } return { root, state, get app() { return app(); }, options, observer, events, prepare }; } async function withRelease(result) { const text = await fs.readFile(path.join(result.appPath, 'Contents/MacOS/galaxy-oss'), 'utf8'); await result.release(); await assert.rejects(fs.access(result.appPath)); return text; } test('first run distrusts an existing bundle; exact identity reuses verified snapshot, not signed output', async () => { const f = await fixture(); await fs.mkdir(path.join(f.app, 'Contents/MacOS'), { recursive: true }); await fs.writeFile(path.join(f.app, 'Contents/MacOS/galaxy-oss'), 'old'); const first = await f.prepare(); assert.equal(first.reused, false); assert.equal(await withRelease(first), 'built 1'); await fs.writeFile(path.join(f.app, 'Contents/MacOS/galaxy-oss'), 'signed and mutated'); const second = await f.prepare(); assert.equal(second.reused, true); assert.equal(await withRelease(second), 'built 1'); assert.equal(f.state.builds, 1); assert.deepEqual(f.state.commands.find((cmd) => cmd[0] === 'cargo' && cmd[1] === 'bundle' && cmd.includes('--release')), ['cargo', 'bundle', '--release', '--format', 'osx', '--bin', 'galaxy-oss', '--package', 'galaxy', '--features', 'release_bundle,extern_plist', '--target', 'aarch64-apple-darwin']); assert.deepEqual(f.events.filter(([index, event]) => index === 2 && event.status === 'done').map(([, event]) => event.detail), ['Built release bundle', 'Reused verified bundle']); }); test('commit, environment, config and toolchain changes each invalidate reuse', async () => { const f = await fixture(); await withRelease(await f.prepare()); const mutations = [ () => { f.state.commit = 'b'.repeat(40); }, () => { f.options.env.BUILD_PROFILE = 'other'; }, async () => { await fs.mkdir(path.join(f.root, '.cargo')); await fs.writeFile(path.join(f.root, '.cargo/config.toml'), '[env]\nEXAMPLE = "1"\n'); }, () => { f.state.rustc = 'rustc 2\nhost: aarch64-apple-darwin'; }, () => { f.state.cargo = 'cargo 2'; }, () => { f.state.bundle = 'cargo-bundle 2'; }, () => { f.state.sdkVersion = '15.0'; }, () => { f.state.sdkBuild = '23B'; }, () => { f.state.developerDir = '/Applications/Xcode.app/Contents/Developer'; }, () => { f.options.arch = 'x64'; }, ]; for (const mutate of mutations) { await mutate(); const result = await f.prepare(); assert.equal(result.reused, false); await result.release(); } assert.equal(f.state.builds, mutations.length + 1); }); test('missing, changed, or extra snapshot entries force a rebuild', async () => { const f = await fixture(); await withRelease(await f.prepare()); const cached = path.join(f.root, 'target/galaxy-build-cache/Galaxy.app/Contents'); for (const mutation of [ () => fs.rm(path.join(cached, 'MacOS/galaxy-oss')), () => fs.writeFile(path.join(cached, 'MacOS/galaxy-oss'), 'tampered'), () => fs.writeFile(path.join(cached, 'extra'), 'added'), ]) { await mutation(); const result = await f.prepare(); assert.equal(result.reused, false); await result.release(); } assert.equal(f.state.builds, 4); }); test('failed build does not record a successful cache identity or trust stale output', async () => { const f = await fixture(); f.state.fail = true; await assert.rejects(f.prepare(), /bundle failed/); await assert.rejects(fs.access(path.join(f.root, 'target/galaxy-build-cache/manifest.json'))); f.state.fail = false; const result = await f.prepare(); assert.equal(result.reused, false); await result.release(); assert.equal(f.state.builds, 2); }); test('dirty and detached checkouts abort before pulling or bundling', async () => { const f = await fixture(); f.state.status = '?? change'; await assert.rejects(f.prepare(), /uncommitted changes/); assert.equal(f.state.commands.some((cmd) => cmd[1] === 'pull'), false); f.state.status = ''; f.state.branch = ''; await assert.rejects(f.prepare(), /detached HEAD/); assert.equal(f.state.builds, 0); }); test('external config and mutable overrides conservatively disable reuse', async () => { const f = await fixture(); await fs.mkdir(path.join(f.options.home, '.cargo'), { recursive: true }); await fs.writeFile(path.join(f.options.home, '.cargo/config.toml'), '[build]\nrustflags = []\n'); await withRelease(await f.prepare()); const second = await f.prepare(); assert.equal(second.reused, false); await second.release(); f.options.env.GALAXY_WORMHOLE_HELPERS_DIR = '/tmp/helpers'; const third = await f.prepare(); assert.equal(third.reused, false); await third.release(); }); test('install and deploy Yarn metadata and upload credentials share the cache', async () => { const f = await fixture(); const installerBin = path.join(f.root, 'script/build-and-install-to-applications/node_modules/.bin'); const deployBin = path.join(f.root, 'script/build-and-deploy-hermes/node_modules/.bin'); const yarnOne = '/var/folders/ab/cdef/T/yarn--1772886695123-0.1948674944443123'; const yarnTwo = '/var/folders/ab/cdef/T/yarn--1772886702989-0.9834001432421421'; Object.assign(f.options.env, { PATH: `${yarnOne}${path.delimiter}${installerBin}${path.delimiter}/usr/bin`, npm_package_name: 'build-and-install-to-applications', npm_package_scripts_start: 'tsx src/index.tsx', npm_lifecycle_script: 'tsx src/index.tsx', npm_lifecycle_event: 'start', INIT_CWD: '/install', npm_config_argv: '{\"original\":[\"install\"]}' }); await withRelease(await f.prepare()); assert.equal(f.state.buildPath, '/usr/bin'); Object.assign(f.options.env, { PATH: `${yarnTwo}${path.delimiter}${deployBin}${path.delimiter}/usr/bin`, npm_package_name: 'build-and-deploy-hermes', npm_package_scripts_start: 'tsx src/index.tsx', INIT_CWD: '/deploy', npm_config_argv: '{\"original\":[\"deploy\"]}', HERMES_PASS: 'secret', HERMES_USER: 'tester' }); const result = await f.prepare(); assert.equal(result.reused, true); await result.release(); assert.equal(f.state.builds, 1); f.options.env.PATH = `${yarnTwo}${path.delimiter}${deployBin}${path.delimiter}/usr/local/custom-tools${path.delimiter}/usr/bin`; const changedTools = await f.prepare(); assert.equal(changedTools.reused, false); assert.equal(f.state.buildPath, '/usr/local/custom-tools:/usr/bin'); await changedTools.release(); }); test('spawned Cargo metadata excludes warning stderr and hides JSON stdout from logs', async () => { const f = await fixture(); const bin = path.join(f.root, 'fake-bin'); await fs.mkdir(bin); const program = `#!/usr/bin/env node import * as fs from 'node:fs'; import path from 'node:path'; const command = path.basename(process.argv[1]); const args = process.argv.slice(2); if (command === 'git') { if (args[0] === 'symbolic-ref') console.log('main'); if (args[0] === 'rev-parse') console.log('a'.repeat(40)); } else if (command === 'cargo' && args[0] === 'metadata') { console.error('warning: metadata stderr'); console.log(JSON.stringify({ target_directory: path.join(process.cwd(), 'target') })); } else if (command === 'cargo' && args[0] === 'bundle' && args.includes('--release')) { const app = path.join(process.cwd(), 'target/aarch64-apple-darwin/release/bundle/osx/Galaxy.app'); fs.mkdirSync(app, { recursive: true }); fs.writeFileSync(path.join(app, 'binary'), 'fake'); } else if (command === 'rustc') console.log('rustc 1\\nhost: aarch64-apple-darwin'); else console.log('fixed version'); `; for (const name of ['git', 'cargo', 'rustc', 'clang', 'xcrun', 'xcode-select']) { const executable = path.join(bin, name); await fs.writeFile(executable, program); await fs.chmod(executable, 0o755); } const events = []; const observer = { updateStep: () => {}, appendLog: (_, line) => events.push(line) }; const result = await prepareGalaxyBuild(f.root, observer, { env: { PATH: `${bin}${path.delimiter}${process.env.PATH}` }, home: f.options.home }); assert.equal(result.reused, false); assert.ok(events.includes('warning: metadata stderr')); assert.equal(events.some((line) => line.includes('target_directory')), false); await result.release(); }); test('Cargo metadata target directory controls bundle path and cache identity', async () => { const f = await fixture(); await withRelease(await f.prepare()); f.state.targetDir = path.join(f.root, 'custom-target'); const result = await f.prepare(); assert.equal(result.reused, false); assert.equal(await withRelease(result), 'built 2'); assert.equal(f.state.builds, 2); }); test('Cargo target configuration and environment are overridden with native host', async () => { const f = await fixture(); await fs.mkdir(path.join(f.root, '.cargo')); await fs.writeFile(path.join(f.root, '.cargo/config.toml'), 'build.target = "x86_64-apple-darwin"\n'); f.options.env.CARGO_BUILD_TARGET = 'x86_64-apple-darwin'; const result = await f.prepare(); assert.equal(result.reused, false); assert.ok(f.state.commands.some((args) => args[0] === 'cargo' && args.includes('--release') && args.at(-2) === '--target' && args.at(-1) === 'aarch64-apple-darwin')); await result.release(); }); test('cached app is rejected and private lease removed if clean HEAD changes at final verification', async () => { const f = await fixture(); await withRelease(await f.prepare()); const originalRun = f.options.run; let headReads = 0; f.options.run = async (name, args, options) => { if (name === 'git' && args[0] === 'rev-parse') { headReads++; if (headReads === 3) f.state.commit = 'b'.repeat(40); } return originalRun(name, args, options); }; await assert.rejects(f.prepare(), /Checkout changed during bundle preparation/); assert.equal(f.state.builds, 1); const cache = path.join(f.root, 'target/galaxy-build-cache'); assert.equal((await fs.readdir(cache)).some((entry) => entry.startsWith('lease-')), false); }); test('failed SDK identity probe still rejects changed HEAD after bundling', async () => { const f = await fixture(); f.state.failSdk = true; f.state.changeCommitOnBuild = true; await assert.rejects(f.prepare(), /Checkout changed during bundle preparation/); await assert.rejects(fs.access(path.join(f.root, 'target/galaxy-build-cache/manifest.json'))); }); test('dead owner lock fails safely with manual removal guidance', async () => { const f = await fixture(); const lock = path.join(f.root, 'target/galaxy-build-cache/prepare.lock'); await fs.mkdir(path.dirname(lock), { recursive: true }); await fs.symlink(JSON.stringify({ pid: 99999999, start: 'old process', token: 'orphan' }), lock); await assert.rejects(f.prepare(), /Stale Galaxy build lock.*remove it manually/); assert.equal(f.state.builds, 0); assert.ok((await fs.lstat(lock)).isSymbolicLink()); }); test('healthy lock owner survives longer than a short wait', async () => { const f = await fixture(); const lock = path.join(f.root, 'target/galaxy-build-cache/prepare.lock'); const firstRun = f.options.run; let finish; const pending = new Promise((resolve) => { finish = resolve; }); let started; const building = new Promise((resolve) => { started = resolve; }); f.options.run = async (name, args, options) => { if (name === 'cargo' && args[0] === 'bundle' && args.includes('--release')) { started(); await pending; } return firstRun(name, args, options); }; const first = f.prepare(); await building; const second = f.prepare(); await new Promise((resolve) => setTimeout(resolve, 1200)); assert.equal(f.state.builds, 0); assert.ok((await fs.lstat(lock)).isSymbolicLink()); finish(); const [a, b] = await Promise.all([first, second]); assert.equal(f.state.builds, 1); await a.release(); await b.release(); }); test('ignored mutable bundle resources disable reuse', async () => { const f = await fixture(); f.state.ignoredResources = 'app/assets/onboarding/ignored-file'; await withRelease(await f.prepare()); const result = await f.prepare(); assert.equal(result.reused, false); await result.release(); }); test('concurrent requests serialize preparation and preserve private signed leases', async () => { const f = await fixture(); const [a, b] = await Promise.all([f.prepare(), f.prepare()]); assert.equal(f.state.builds, 1); await fs.writeFile(path.join(a.appPath, 'Contents/MacOS/galaxy-oss'), 'signed'); assert.equal(await withRelease(b), 'built 1'); await a.release(); });