From 5bc3e9bee5407f220fb03d550739da3c85a6f756 Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Wed, 11 Mar 2026 21:51:51 -0700 Subject: [PATCH 1/4] perf: detect Mach-O via magic bytes and parallelize test suite Runtime improvements: - Replace `file --brief --no-pad` spawn-per-file with direct magic byte reads in getAllAppFiles. Adds isMachO() helper with disambiguation between Mach-O fat binaries and Java .class files (both 0xCAFEBABE; bytes 4-7 are nfat_arch for Mach-O, major/minor version for Java). - Try APFS copy-on-write clone (cp -cR) when staging the x64 template, falling back to cp -R on non-APFS volumes. - Resolve appPath through realpath before walking to avoid symlink edge cases. Test suite improvements: - Run fixture templateApp setup in parallel (globalSetup). - Convert makeUniversalApp suite to describe.concurrent with per-test mkdtemp output dirs; pass ExpectStatic through helpers so snapshots attribute to the right test under concurrency. - Extract Electron zip into a unique tmpdir in templateApp to avoid races on the intermediate Electron.app path. - Use mkdtemp for staging app dirs instead of random-suffix collision avoidance. - Cap maxConcurrency at 4. --- src/asar-utils.ts | 15 +--- src/file-utils.ts | 51 ++++++++---- src/index.ts | 9 ++- test/globalSetup.ts | 90 +++++++++++---------- test/index.spec.ts | 192 ++++++++++++++++++++++++-------------------- test/util.ts | 36 ++++----- vitest.config.ts | 1 + 7 files changed, 213 insertions(+), 181 deletions(-) diff --git a/src/asar-utils.ts b/src/asar-utils.ts index aaeed61..94077b7 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -8,6 +8,7 @@ import * as asar from '@electron/asar'; import { minimatch } from 'minimatch'; import { d } from './debug.js'; +import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC } from './file-utils.js'; const LIPO = 'lipo'; @@ -24,20 +25,6 @@ export type MergeASARsOptions = { singleArchFiles?: string; }; -// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112 -const MACHO_MAGIC = new Set([ - // 32-bit Mach-O - 0xfeedface, 0xcefaedfe, - - // 64-bit Mach-O - 0xfeedfacf, 0xcffaedfe, -]); - -const MACHO_UNIVERSAL_MAGIC = new Set([ - // universal - 0xcafebabe, 0xbebafeca, -]); - export const detectAsarMode = async (appPath: string) => { d('checking asar mode of', appPath); const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); diff --git a/src/file-utils.ts b/src/file-utils.ts index acefa0a..5d1e248 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -2,10 +2,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { promises as stream } from 'node:stream'; -import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; import { minimatch } from 'minimatch'; -const MACHO_PREFIX = 'Mach-O '; +// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112 +export const MACHO_MAGIC = new Set([ + // 32-bit Mach-O + 0xfeedface, 0xcefaedfe, + + // 64-bit Mach-O + 0xfeedfacf, 0xcffaedfe, +]); + +export const MACHO_UNIVERSAL_MAGIC = new Set([ + // universal + 0xcafebabe, 0xbebafeca, +]); + +// Java .class files share the 0xCAFEBABE magic with Mach-O fat binaries. For +// Mach-O, bytes 4-7 encode nfat_arch (a small integer); for Java they encode +// (minor_version << 16 | major_version) where major_version >= 45. Any value +// below 30 is safely Mach-O. +const FAT_ARCH_DISAMBIGUATION_THRESHOLD = 30; + +export const isMachO = (header: Buffer): boolean => { + if (header.length < 4) return false; + const magic = header.readUInt32LE(0); + if (MACHO_MAGIC.has(magic)) return true; + if (MACHO_UNIVERSAL_MAGIC.has(magic)) { + if (header.length < 8) return true; + return header.readUInt32BE(4) < FAT_ARCH_DISAMBIGUATION_THRESHOLD; + } + return false; +}; const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked'); @@ -52,7 +80,7 @@ export const getAllAppFiles = async ( appPath: string, opts: GetAllAppFilesOpts, ): Promise => { - const unpackedPath = path.join('Contents', 'Resources', 'app.asar.unpacked'); + appPath = await fs.promises.realpath(appPath); const files: AppFile[] = []; @@ -69,21 +97,11 @@ export const getAllAppFiles = async ( let fileType = AppFileType.PLAIN; - var fileOutput = ''; - try { - fileOutput = await spawn('file', ['--brief', '--no-pad', p]); - } catch (e) { - if (e instanceof ExitCodeError) { - /* silently accept error codes from "file" */ - } else { - throw e; - } - } if (p.endsWith('.asar')) { fileType = AppFileType.APP_CODE; } else if (isSingleArchFile(relativePath, opts)) { fileType = AppFileType.SINGLE_ARCH; - } else if (fileOutput.startsWith(MACHO_PREFIX)) { + } else if (isMachO(await readMachOHeader(p))) { fileType = AppFileType.MACHO; } else if (p.endsWith('.bin')) { fileType = AppFileType.SNAPSHOT; @@ -110,8 +128,9 @@ export const getAllAppFiles = async ( export const readMachOHeader = async (path: string) => { const chunks: Buffer[] = []; - // no need to read the entire file, we only need the first 4 bytes of the file to determine the header - await stream.pipeline(fs.createReadStream(path, { start: 0, end: 3 }), async function* (source) { + // no need to read the entire file, we only need the first 8 bytes to + // identify the Mach-O magic (and disambiguate fat binaries from Java .class) + await stream.pipeline(fs.createReadStream(path, { start: 0, end: 7 }), async function* (source) { for await (const chunk of source) { chunks.push(chunk); } diff --git a/src/index.ts b/src/index.ts index 8593496..bbcb5a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,7 +122,14 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = try { d('copying x64 app as starter template'); const tmpApp = path.resolve(tmpDir, 'Tmp.app'); - await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); + try { + // On APFS (standard on modern macOS), -c does a copy-on-write clone + // that's near-instant even for multi-hundred-MB apps. + await spawn('cp', ['-cR', opts.x64AppPath, tmpApp]); + } catch { + // -c fails on non-APFS volumes; fall back to a regular copy. + await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); + } const uniqueToX64: string[] = []; const uniqueToArm64: string[] = []; diff --git a/test/globalSetup.ts b/test/globalSetup.ts index 1104c57..4d0dfbf 100644 --- a/test/globalSetup.ts +++ b/test/globalSetup.ts @@ -30,53 +30,55 @@ export default async () => { // generate mach-o binaries to be leveraged in lipo tests generateMachO(); - await templateApp('Arm64Asar.app', 'arm64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app.asar'), - path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + await Promise.all([ + templateApp('Arm64Asar.app', 'arm64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app.asar'), + path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), + { recursive: true, verbatimSymlinks: true }, + ); + }), - // contains `extra-file.txt` - await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app2.asar'), - path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + // contains `extra-file.txt` + templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app2.asar'), + path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), + { recursive: true, verbatimSymlinks: true }, + ); + }), - await templateApp('X64Asar.app', 'x64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app.asar'), - path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + templateApp('X64Asar.app', 'x64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app.asar'), + path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), + { recursive: true, verbatimSymlinks: true }, + ); + }), - await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app'), - path.resolve(appPath, 'Contents', 'Resources', 'app'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app'), + path.resolve(appPath, 'Contents', 'Resources', 'app'), + { recursive: true, verbatimSymlinks: true }, + ); + }), - // contains `extra-file.txt` - await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app2'), - path.resolve(appPath, 'Contents', 'Resources', 'app'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + // contains `extra-file.txt` + templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app2'), + path.resolve(appPath, 'Contents', 'Resources', 'app'), + { recursive: true, verbatimSymlinks: true }, + ); + }), - await templateApp('X64NoAsar.app', 'x64', async (appPath) => { - await fs.promises.cp( - path.resolve(asarsDir, 'app'), - path.resolve(appPath, 'Contents', 'Resources', 'app'), - { recursive: true, verbatimSymlinks: true }, - ); - }); + templateApp('X64NoAsar.app', 'x64', async (appPath) => { + await fs.promises.cp( + path.resolve(asarsDir, 'app'), + path.resolve(appPath, 'Contents', 'Resources', 'app'), + { recursive: true, verbatimSymlinks: true }, + ); + }), + ]); }; diff --git a/test/index.spec.ts b/test/index.spec.ts index 4a38abe..0ce3fcc 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterAll, describe, it } from 'vitest'; import { makeUniversalApp } from '../dist/index.js'; import { fsMove } from '../src/file-utils.js'; @@ -15,17 +16,22 @@ import { import { createPackage, createPackageWithOptions } from '@electron/asar'; const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps'); -const appsOutPath = path.resolve(import.meta.dirname, 'fixtures', 'apps', 'out'); + +const tmpDirs: string[] = []; +const mkOutDir = async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'electron-universal-test-')); + tmpDirs.push(dir); + return dir; +}; // See `globalSetup.ts` for app fixture setup process -describe('makeUniversalApp', () => { - afterEach(async () => { - await fs.promises.rm(appsOutPath, { force: true, recursive: true }); - await fs.promises.mkdir(appsOutPath, { recursive: true }); +describe.concurrent('makeUniversalApp', () => { + afterAll(async () => { + await Promise.all(tmpDirs.map((d) => fs.promises.rm(d, { force: true, recursive: true }))); }); - it('throws an error if asar is only detected in one arch', async () => { - const out = path.resolve(appsOutPath, 'Error.app'); + it('throws an error if asar is only detected in one arch', async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'Error.app'); await expect( makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), @@ -37,7 +43,7 @@ describe('makeUniversalApp', () => { ); }); - it('works for lipo binary resources', { timeout: VERIFY_APP_TIMEOUT }, async () => { + it('works for lipo binary resources', { timeout: VERIFY_APP_TIMEOUT }, async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'LipoX64.app', arch: 'x64', @@ -49,14 +55,16 @@ describe('makeUniversalApp', () => { createAsar: true, }); - const out = path.resolve(appsOutPath, 'Lipo.app'); + const out = path.resolve(await mkOutDir(), 'Lipo.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true }); - await verifyApp(out, true); + await verifyApp(expect, out, true); }); describe('force', () => { - it('throws an error if `out` bundle already exists and `force` is `false`', async () => { - const out = path.resolve(appsOutPath, 'Error.app'); + it('throws an error if `out` bundle already exists and `force` is `false`', async ({ + expect, + }) => { + const out = path.resolve(await mkOutDir(), 'Error.app'); await fs.promises.mkdir(out, { recursive: true }); await expect( makeUniversalApp({ @@ -70,8 +78,8 @@ describe('makeUniversalApp', () => { it( 'packages successfully if `out` bundle already exists and `force` is `true`', { timeout: VERIFY_APP_TIMEOUT }, - async () => { - const out = path.resolve(appsOutPath, 'NoError.app'); + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'NoError.app'); await fs.promises.mkdir(out, { recursive: true }); await makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), @@ -79,41 +87,45 @@ describe('makeUniversalApp', () => { outAppPath: out, force: true, }); - await verifyApp(out); + await verifyApp(expect, out); }, ); }); describe('asar mode', () => { - it('should correctly merge two identical asars', { timeout: VERIFY_APP_TIMEOUT }, async () => { - const out = path.resolve(appsOutPath, 'MergedAsar.app'); - await makeUniversalApp({ - x64AppPath: path.resolve(appsPath, 'X64Asar.app'), - arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), - outAppPath: out, - }); - await verifyApp(out); - }); + it( + 'should correctly merge two identical asars', + { timeout: VERIFY_APP_TIMEOUT }, + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'MergedAsar.app'); + await makeUniversalApp({ + x64AppPath: path.resolve(appsPath, 'X64Asar.app'), + arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), + outAppPath: out, + }); + await verifyApp(expect, out); + }, + ); it( 'should create a shim if asars are different between architectures', { timeout: VERIFY_APP_TIMEOUT }, - async () => { - const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'ShimmedAsar.app'); await makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), outAppPath: out, }); - await verifyApp(out); + await verifyApp(expect, out); }, ); it( 'should merge two different asars when `mergeASARs` is enabled', { timeout: VERIFY_APP_TIMEOUT }, - async () => { - const out = path.resolve(appsOutPath, 'MergedAsar.app'); + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'MergedAsar.app'); await makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), @@ -121,15 +133,15 @@ describe('makeUniversalApp', () => { mergeASARs: true, singleArchFiles: 'extra-file.txt', }); - await verifyApp(out); + await verifyApp(expect, out); }, ); it( 'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', { timeout: VERIFY_APP_TIMEOUT }, - async () => { - const out = path.resolve(appsOutPath, 'Error.app'); + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'Error.app'); await expect( makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), @@ -145,7 +157,7 @@ describe('makeUniversalApp', () => { it( 'should merge two different asars with native files when `mergeASARs` is enabled', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'SingleArchFiles-x64.app', arch: 'x64', @@ -158,7 +170,7 @@ describe('makeUniversalApp', () => { createAsar: true, singleArchBindings: true, }); - const out = path.resolve(appsOutPath, 'SingleArchFiles.app'); + const out = path.resolve(await mkOutDir(), 'SingleArchFiles.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, @@ -166,14 +178,14 @@ describe('makeUniversalApp', () => { mergeASARs: true, singleArchFiles: 'hello-world-*', }); - await verifyApp(out, true); + await verifyApp(expect, out, true); }, ); it( 'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique native file', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'SingleArchFiles-2-x64.app', arch: 'x64', @@ -186,7 +198,7 @@ describe('makeUniversalApp', () => { createAsar: true, singleArchBindings: true, }); - const out = path.resolve(appsOutPath, 'SingleArchFiles-2.app'); + const out = path.resolve(await mkOutDir(), 'SingleArchFiles-2.app'); await expect( makeUniversalApp({ x64AppPath, @@ -204,7 +216,7 @@ describe('makeUniversalApp', () => { it( 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => { const { testPath } = await createStagingAppDir('Arm64-1'); await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); @@ -225,7 +237,7 @@ describe('makeUniversalApp', () => { ); }); }); - const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app'); + const outAppPath = path.resolve(await mkOutDir(), 'UnmodifiedPlist.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, @@ -233,7 +245,7 @@ describe('makeUniversalApp', () => { mergeASARs: true, infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist', }); - await verifyApp(outAppPath); + await verifyApp(expect, outAppPath); }, ); @@ -243,7 +255,7 @@ describe('makeUniversalApp', () => { it.skip( 'should shim asars with different unpacked dirs', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => { const { testPath } = await createStagingAppDir('UnpackedAppArm64'); await createPackageWithOptions( @@ -265,22 +277,22 @@ describe('makeUniversalApp', () => { ); }); - const outAppPath = path.resolve(appsOutPath, 'UnpackedDir.app'); + const outAppPath = path.resolve(await mkOutDir(), 'UnpackedDir.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath, }); - await verifyApp(outAppPath); + await verifyApp(expect, outAppPath); }, ); it( 'should generate AsarIntegrity for all asars in the application', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const { testPath } = await createStagingAppDir('app-2'); - const testAsarPath = path.resolve(appsOutPath, 'app-2.asar'); + const testAsarPath = path.resolve(await mkOutDir(), 'app-2.asar'); await createPackage(testPath, testAsarPath); const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => { @@ -303,14 +315,14 @@ describe('makeUniversalApp', () => { path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'), ); }); - const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app'); + const outAppPath = path.resolve(await mkOutDir(), 'MultipleAsars.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath, mergeASARs: true, }); - await verifyApp(outAppPath); + await verifyApp(expect, outAppPath); }, ); }); @@ -319,51 +331,55 @@ describe('makeUniversalApp', () => { it( 'should correctly merge two identical app folders', { timeout: VERIFY_APP_TIMEOUT }, - async () => { - const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); + async ({ expect }) => { + const out = path.resolve(await mkOutDir(), 'MergedNoAsar.app'); await makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'), arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), outAppPath: out, }); - await verifyApp(out); + await verifyApp(expect, out); }, ); - it('should shim two different app folders', { timeout: VERIFY_APP_TIMEOUT }, async () => { - const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { - const { testPath } = await createStagingAppDir('shimArm64', { - 'i-aint-got-no-rhythm.bin': 'boomshakalaka', - }); - await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), { - recursive: true, - verbatimSymlinks: true, + it( + 'should shim two different app folders', + { timeout: VERIFY_APP_TIMEOUT }, + async ({ expect }) => { + const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { + const { testPath } = await createStagingAppDir('shimArm64', { + 'i-aint-got-no-rhythm.bin': 'boomshakalaka', + }); + await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), { + recursive: true, + verbatimSymlinks: true, + }); }); - }); - const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { - const { testPath } = await createStagingAppDir('shimX64', { - 'hello-world.bin': 'Hello World', - }); - await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), { - recursive: true, - verbatimSymlinks: true, + const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { + const { testPath } = await createStagingAppDir('shimX64', { + 'hello-world.bin': 'Hello World', + }); + await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), { + recursive: true, + verbatimSymlinks: true, + }); }); - }); - const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app'); - await makeUniversalApp({ - x64AppPath, - arm64AppPath, - outAppPath, - }); - await verifyApp(outAppPath); - }); + const outAppPath = path.resolve(await mkOutDir(), 'ShimNoAsar.app'); + await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath, + }); + await verifyApp(expect, outAppPath); + }, + ); it( 'different app dirs with different macho files (shim and lipo)', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'DifferentMachoAppX64-1.app', arch: 'x64', @@ -381,20 +397,20 @@ describe('makeUniversalApp', () => { }, }); - const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app'); + const outAppPath = path.resolve(await mkOutDir(), 'DifferentMachoApp1.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath, }); - await verifyApp(outAppPath, true); + await verifyApp(expect, outAppPath, true); }, ); it( "different app dirs with universal macho files (shim but don't lipo)", { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'DifferentButUniversalMachoAppX64-2.app', arch: 'x64', @@ -414,20 +430,20 @@ describe('makeUniversalApp', () => { }, }); - const outAppPath = path.resolve(appsOutPath, 'DifferentButUniversalMachoApp.app'); + const outAppPath = path.resolve(await mkOutDir(), 'DifferentButUniversalMachoApp.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath, }); - await verifyApp(outAppPath, true); + await verifyApp(expect, outAppPath, true); }, ); it( 'identical app dirs with different macho files (e.g. do not shim, but still lipo)', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'DifferentMachoAppX64-2.app', arch: 'x64', @@ -439,20 +455,20 @@ describe('makeUniversalApp', () => { createAsar: false, }); - const out = path.resolve(appsOutPath, 'DifferentMachoApp2.app'); + const out = path.resolve(await mkOutDir(), 'DifferentMachoApp2.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, }); - await verifyApp(out, true); + await verifyApp(expect, out, true); }, ); it( 'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)', { timeout: VERIFY_APP_TIMEOUT }, - async () => { + async ({ expect }) => { const x64AppPath = await generateNativeApp({ appNameWithExtension: 'UniversalMachoAppX64.app', arch: 'x64', @@ -466,9 +482,9 @@ describe('makeUniversalApp', () => { nativeModuleArch: 'universal', }); - const out = path.resolve(appsOutPath, 'UniversalMachoApp.app'); + const out = path.resolve(await mkOutDir(), 'UniversalMachoApp.app'); await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out }); - await verifyApp(out, true); + await verifyApp(expect, out, true); }, ); }); diff --git a/test/util.ts b/test/util.ts index 596fe54..3ad1356 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { createPackageWithOptions, getRawHeader } from '@electron/asar'; @@ -6,6 +7,7 @@ import { downloadArtifact } from '@electron/get'; import { spawn } from '@malept/cross-spawn-promise'; import * as zip from 'cross-zip'; import plist from 'plist'; +import type { ExpectStatic } from 'vitest'; import * as fileUtils from '../dist/file-utils.js'; @@ -17,12 +19,13 @@ export const VERIFY_APP_TIMEOUT = 80 * 1000; export const fixtureDir = path.resolve(import.meta.dirname, 'fixtures'); export const asarsDir = path.resolve(fixtureDir, 'asars'); export const appsDir = path.resolve(fixtureDir, 'apps'); -export const appsOutPath = path.resolve(appsDir, 'out'); -export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => { - const { expect } = await import('vitest'); - - await ensureUniversal(appPath); +export const verifyApp = async ( + expect: ExpectStatic, + appPath: string, + containsRuntimeGeneratedMacho = false, +) => { + await ensureUniversal(expect, appPath); const resourcesDir = path.resolve(appPath, 'Contents', 'Resources'); const resourcesDirContents = await fs.promises.readdir(resourcesDir); @@ -55,7 +58,7 @@ export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = .filter((p) => dirsToSnapshot.includes(path.basename(p))) .sort(); for await (const dir of appDirs) { - await verifyFileTree(path.resolve(resourcesDir, dir)); + await verifyFileTree(expect, path.resolve(resourcesDir, dir)); } const allFiles = await fileUtils.getAllAppFiles(appPath, {}); @@ -91,9 +94,7 @@ const extractAsarIntegrity = async (infoPlist: string) => { return integrity; }; -export const verifyFileTree = async (dirPath: string) => { - const { expect } = await import('vitest'); - +export const verifyFileTree = async (expect: ExpectStatic, dirPath: string) => { const dirFiles = await fileUtils.getAllAppFiles(dirPath, {}); const files = dirFiles.map((file) => { const it = path.join(dirPath, file.relativePath); @@ -106,9 +107,7 @@ export const verifyFileTree = async (dirPath: string) => { expect(files).toMatchSnapshot(); }; -export const ensureUniversal = async (app: string) => { - const { expect } = await import('vitest'); - +export const ensureUniversal = async (expect: ExpectStatic, app: string) => { const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); const result = await spawn(exe); expect(result).toContain('arm64'); @@ -171,10 +170,7 @@ export const createStagingAppDir = async ( testName: string | undefined, additionalFiles: Record = {}, ) => { - const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions - const testPath = path.join(appsDir, outDir); - await fs.promises.rm(testPath, { recursive: true, force: true }); - + const testPath = await fs.promises.mkdtemp(path.join(os.tmpdir(), `${testName || 'app'}-`)); await fs.promises.cp(path.join(asarsDir, 'app'), testPath, { recursive: true, verbatimSymlinks: true, @@ -216,9 +212,13 @@ export const templateApp = async ( platform: 'darwin', arch, }); + // unzip to a unique tmpdir so concurrent calls don't race on the intermediate + // Electron.app path + const extractDir = await fs.promises.mkdtemp(path.join(appsDir, '.extract-')); const appPath = path.resolve(appsDir, name); - zip.unzipSync(electronZip, appsDir); - await fs.promises.rename(path.resolve(appsDir, 'Electron.app'), appPath); + zip.unzipSync(electronZip, extractDir); + await fs.promises.rename(path.resolve(extractDir, 'Electron.app'), appPath); + await fs.promises.rm(extractDir, { recursive: true, force: true }); await fs.promises.rm(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'), { recursive: true, force: true, diff --git a/vitest.config.ts b/vitest.config.ts index 4a9b9a7..8f3ca26 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globalSetup: './test/globalSetup.ts', + maxConcurrency: 4, }, }); From 43801fbd39c290f422ead6e81100a8fbe27d323e Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Wed, 11 Mar 2026 21:59:41 -0700 Subject: [PATCH 2/4] test: pre-warm electron download cache before parallel fixture setup @electron/get is not safe for concurrent downloads of the same artifact. On CI with a cold cache, the parallel templateApp calls in globalSetup raced writing to the same cache path and produced a corrupt/missing zip. Warm the cache for both archs serially in globalSetup before any parallel work starts; all subsequent templateApp calls (in globalSetup and in the concurrent test suite) then hit the cached zip. --- test/globalSetup.ts | 8 +++++++- test/util.ts | 15 +++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/test/globalSetup.ts b/test/globalSetup.ts index 4d0dfbf..baf0693 100644 --- a/test/globalSetup.ts +++ b/test/globalSetup.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { appsDir, asarsDir, fixtureDir, templateApp } from './util.js'; +import { appsDir, asarsDir, downloadElectronZip, fixtureDir, templateApp } from './util.js'; // generates binaries from hello-world.c // hello-world-universal, hello-world-x86_64, hello-world-arm64 @@ -30,6 +30,12 @@ export default async () => { // generate mach-o binaries to be leveraged in lipo tests generateMachO(); + // @electron/get is not safe for concurrent downloads of the same artifact — + // warm the cache serially before the parallel templateApp calls (here and in + // the concurrent test suite) so they all hit the cached zip. + await downloadElectronZip('arm64'); + await downloadElectronZip('x64'); + await Promise.all([ templateApp('Arm64Asar.app', 'arm64', async (appPath) => { await fs.promises.cp( diff --git a/test/util.ts b/test/util.ts index 3ad1356..007af00 100644 --- a/test/util.ts +++ b/test/util.ts @@ -201,17 +201,20 @@ export const createStagingAppDir = async ( }; }; -export const templateApp = async ( - name: string, - arch: string, - modify: (appPath: string) => Promise, -) => { - const electronZip = await downloadArtifact({ +export const downloadElectronZip = (arch: string) => + downloadArtifact({ artifactName: 'electron', version: '27.0.0', platform: 'darwin', arch, }); + +export const templateApp = async ( + name: string, + arch: string, + modify: (appPath: string) => Promise, +) => { + const electronZip = await downloadElectronZip(arch); // unzip to a unique tmpdir so concurrent calls don't race on the intermediate // Electron.app path const extractDir = await fs.promises.mkdtemp(path.join(appsDir, '.extract-')); From 4b23db3f51984b354c6847dd6a4bdb5747ed305b Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Wed, 11 Mar 2026 22:15:45 -0700 Subject: [PATCH 3/4] ci: use macos-latest-xlarge runner for tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11a1bde..2c4c5bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: matrix: node-version: - 22.12.x - runs-on: macos-latest + runs-on: macos-latest-xlarge steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 8750ab015f99c757e672478dd46ab9fec462923e Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Fri, 13 Mar 2026 15:18:39 -0700 Subject: [PATCH 4/4] chore: add debug logging for APFS clone vs regular copy path --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index bbcb5a4..b80c6a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,14 +120,15 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = d('building universal app in', tmpDir); try { - d('copying x64 app as starter template'); const tmpApp = path.resolve(tmpDir, 'Tmp.app'); try { // On APFS (standard on modern macOS), -c does a copy-on-write clone // that's near-instant even for multi-hundred-MB apps. + d('copying x64 app as starter template via APFS clone (cp -cR)'); await spawn('cp', ['-cR', opts.x64AppPath, tmpApp]); } catch { // -c fails on non-APFS volumes; fall back to a regular copy. + d('APFS clone unsupported, falling back to regular cp -R'); await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); }