diff --git a/jest.config.js b/jest.config.js index e76956f..978133c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,13 +3,14 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', transform: { - '.': [ + '^.+\\.ts?$': [ 'ts-jest', { - tsconfig: 'tsconfig.jest.json' - } - ] + tsconfig: 'tsconfig.jest.json', + }, + ], }, + testMatch: ['/test/**/*.spec.ts'], globalSetup: './jest.setup.ts', testTimeout: 10000, -}; \ No newline at end of file +}; diff --git a/jest.setup.ts b/jest.setup.ts index a8d3b35..296a148 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,28 +1,6 @@ -import { downloadArtifact } from '@electron/get'; -import * as zip from 'cross-zip'; import * as fs from 'fs-extra'; import * as path from 'path'; - -const asarsDir = path.resolve(__dirname, 'test', 'fixtures', 'asars'); -const appsDir = path.resolve(__dirname, 'test', 'fixtures', 'apps'); - -const templateApp = async ( - name: string, - arch: string, - modify: (appPath: string) => Promise, -) => { - const electronZip = await downloadArtifact({ - artifactName: 'electron', - version: '27.0.0', - platform: 'darwin', - arch, - }); - const appPath = path.resolve(appsDir, name); - zip.unzipSync(electronZip, appsDir); - await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath); - await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar')); - await modify(appPath); -}; +import { appsDir, asarsDir, templateApp } from './test/util'; export default async () => { await fs.remove(appsDir); diff --git a/src/asar-utils.ts b/src/asar-utils.ts index 9633051..dcadf8a 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -84,8 +84,10 @@ export const mergeASARs = async ({ }: MergeASARsOptions): Promise => { d(`merging ${x64AsarPath} and ${arm64AsarPath}`); - const x64Files = new Set(asar.listPackage(x64AsarPath).map(toRelativePath)); - const arm64Files = new Set(asar.listPackage(arm64AsarPath).map(toRelativePath)); + const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath)); + const arm64Files = new Set( + asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath), + ); // // Build set of unpacked directories and files diff --git a/test/__snapshots__/index.spec.ts.snap b/test/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000..2d0341d --- /dev/null +++ b/test/__snapshots__/index.spec.ts.snap @@ -0,0 +1,410 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`makeUniversalApp asar mode should correctly merge two identical asars 1`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + ], + "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + }, + "size": 64, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should correctly merge two identical asars 2`] = ` +{ + "Contents/Info.plist": { + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 1`] = ` +{ + "files": { + "extra-file.txt": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", + ], + "hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", + }, + "size": 15, + }, + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + ], + "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + }, + "size": 64, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 2`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + ], + "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + }, + "size": 64, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 3`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b", + ], + "hash": "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b", + }, + "size": 1068, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", + ], + "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", + }, + "size": 33, + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 4`] = ` +{ + "Contents/Info.plist": { + "Resources/app-arm64.asar": { + "algorithm": "SHA256", + "hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d", + }, + "Resources/app-x64.asar": { + "algorithm": "SHA256", + "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", + }, + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "b62aaaed07ff72dc33da1720d900e0443c060285ef374ce1bdaef1d4f28b5fe4", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 1`] = ` +{ + "files": { + "extra-file.txt": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", + ], + "hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", + }, + "size": 15, + }, + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + ], + "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + }, + "size": 64, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 2`] = ` +{ + "Contents/Info.plist": { + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + ], + "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", + }, + "size": 66, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + "private": { + "files": { + "var": { + "files": { + "app": { + "files": { + "file.txt": { + "link": "private/var/file.txt", + }, + }, + }, + "file.txt": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ], + "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + "size": 11, + }, + }, + }, + }, + }, + "var": { + "link": "private/var", + }, + }, +} +`; + +exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = ` +{ + "Contents/Info.plist": undefined, + "Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined, +} +`; + +exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 1`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + ], + "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", + }, + "size": 64, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + ], + "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", + }, + "size": 41, + }, + }, +} +`; + +exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = ` +{ + "Contents/Info.plist": { + "Resources/app.asar": { + "algorithm": "SHA256", + "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", + }, + }, +} +`; + +exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 1`] = ` +[ + "index.js", + { + "content": "{ + "name": "app", + "main": "index.js" +}", + "name": "package.json", + }, +] +`; + +exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 2`] = ` +{ + "Contents/Info.plist": {}, +} +`; + +exports[`makeUniversalApp no asar mode should shim two different app folders 1`] = ` +{ + "files": { + "index.js": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", + ], + "hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", + }, + "size": 1063, + }, + "package.json": { + "integrity": { + "algorithm": "SHA256", + "blockSize": 4194304, + "blocks": [ + "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", + ], + "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", + }, + "size": 33, + }, + }, +} +`; + +exports[`makeUniversalApp no asar mode should shim two different app folders 2`] = ` +[ + "private/var/i-aint-got-no-rhythm.bin", +] +`; + +exports[`makeUniversalApp no asar mode should shim two different app folders 3`] = ` +[ + "index.js", + { + "content": "{ + "name": "app", + "main": "index.js" +}", + "name": "package.json", + }, + { + "content": "hello world", + "name": "private/var/file.txt", + }, + "private/var/i-aint-got-no-rhythm.bin", +] +`; + +exports[`makeUniversalApp no asar mode should shim two different app folders 4`] = ` +[ + "index.js", + { + "content": "{ + "name": "app", + "main": "index.js" +}", + "name": "package.json", + }, + { + "content": "hello world", + "name": "private/var/file.txt", + }, + "private/var/hello-world.bin", +] +`; + +exports[`makeUniversalApp no asar mode should shim two different app folders 5`] = ` +{ + "Contents/Info.plist": {}, +} +`; diff --git a/test/index.spec.ts b/test/index.spec.ts index 0d9871b..fffc2b9 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,20 +1,13 @@ -import { spawn } from '@malept/cross-spawn-promise'; import * as fs from 'fs-extra'; import * as path from 'path'; import { makeUniversalApp } from '../dist/cjs/index'; +import { createTestApp, templateApp, VERIFY_APP_TIMEOUT, verifyApp } from './util'; +import { createPackage } from '@electron/asar'; const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); -async function ensureUniversal(app: string) { - const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); - const result = await spawn(exe); - expect(result).toContain('arm64'); - const result2 = await spawn('arch', ['-x86_64', exe]); - expect(result2).toContain('x64'); -} - // See `jest.setup.ts` for app fixture setup process describe('makeUniversalApp', () => { afterEach(async () => { @@ -49,110 +42,162 @@ describe('makeUniversalApp', () => { ).rejects.toThrow(/The out path ".*" already exists and force is not set to true/); }); - it('packages successfully if `out` bundle already exists and `force` is `true`', async () => { - const out = path.resolve(appsOutPath, 'Error.app'); - await fs.mkdirp(out); - await makeUniversalApp({ - x64AppPath: path.resolve(appsPath, 'X64Asar.app'), - arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), - outAppPath: out, - force: true, - }); - await ensureUniversal(out); - // Only a single asar as they were identical - expect( - (await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) => - p.endsWith('asar'), - ), - ).toEqual(['app.asar']); - }, 60000); + it( + 'packages successfully if `out` bundle already exists and `force` is `true`', + async () => { + const out = path.resolve(appsOutPath, 'Error.app'); + await fs.mkdirp(out); + await makeUniversalApp({ + x64AppPath: path.resolve(appsPath, 'X64Asar.app'), + arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), + outAppPath: out, + force: true, + }); + await verifyApp(out); + }, + VERIFY_APP_TIMEOUT, + ); }); describe('asar mode', () => { - it('should correctly merge two identical asars', 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 ensureUniversal(out); - // Only a single asar as they were identical - expect( - (await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) => - p.endsWith('asar'), - ), - ).toEqual(['app.asar']); - }, 60000); + it( + 'should correctly merge two identical asars', + 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); + }, + VERIFY_APP_TIMEOUT, + ); - it('should create a shim if asars are different between architectures', async () => { - const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); - await makeUniversalApp({ - x64AppPath: path.resolve(appsPath, 'X64Asar.app'), - arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), - outAppPath: out, - }); - await ensureUniversal(out); - // We have three asars including the arch-agnostic shim - expect( - (await fs.readdir(path.resolve(out, 'Contents', 'Resources'))) - .filter((p) => p.endsWith('asar')) - .sort(), - ).toEqual(['app.asar', 'app-x64.asar', 'app-arm64.asar'].sort()); - }, 60000); + it( + 'should create a shim if asars are different between architectures', + async () => { + const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); + await makeUniversalApp({ + x64AppPath: path.resolve(appsPath, 'X64Asar.app'), + arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), + outAppPath: out, + }); + await verifyApp(out); + }, + VERIFY_APP_TIMEOUT, + ); - it('should merge two different asars when `mergeASARs` is enabled', async () => { - const out = path.resolve(appsOutPath, 'MergedAsar.app'); - await makeUniversalApp({ - x64AppPath: path.resolve(appsPath, 'X64Asar.app'), - arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), - outAppPath: out, - mergeASARs: true, - singleArchFiles: 'extra-file.txt', - }); - await ensureUniversal(out); - // Only a single merged asar - expect( - (await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) => - p.endsWith('asar'), - ), - ).toEqual(['app.asar']); - }, 60000); - - it('throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', async () => { - const out = path.resolve(appsOutPath, 'Error.app'); - await expect( - makeUniversalApp({ + it( + 'should merge two different asars when `mergeASARs` is enabled', + async () => { + const out = path.resolve(appsOutPath, 'MergedAsar.app'); + await makeUniversalApp({ x64AppPath: path.resolve(appsPath, 'X64Asar.app'), arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), outAppPath: out, mergeASARs: true, - singleArchFiles: 'bad-rule', - }), - ).rejects.toThrow(/Detected unique file "extra-file\.txt"/); - }, 60000); + singleArchFiles: 'extra-file.txt', + }); + await verifyApp(out); + }, + VERIFY_APP_TIMEOUT, + ); - it.todo('should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`'); + it( + 'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', + async () => { + const out = path.resolve(appsOutPath, 'Error.app'); + await expect( + makeUniversalApp({ + x64AppPath: path.resolve(appsPath, 'X64Asar.app'), + arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), + outAppPath: out, + mergeASARs: true, + singleArchFiles: 'bad-rule', + }), + ).rejects.toThrow(/Detected unique file "extra-file\.txt"/); + }, + VERIFY_APP_TIMEOUT, + ); + + it( + 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', + async () => { + const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => { + const { testPath } = await createTestApp('Arm64-1'); + await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); + await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => { + await fs.move( + subArm64AppPath, + path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), + ); + }); + }); + const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => { + const { testPath } = await createTestApp('X64-1'); + await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); + await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => { + await fs.move( + subArm64AppPath, + path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), + ); + }); + }); + const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app'); + await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath, + mergeASARs: true, + infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist', + }); + await verifyApp(outAppPath); + }, + VERIFY_APP_TIMEOUT, + ); }); describe('no asar mode', () => { - it('should correctly merge two identical app folders', async () => { - const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); - await makeUniversalApp({ - x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'), - arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), - outAppPath: out, - }); - await ensureUniversal(out); - // Only a single app folder as they were identical - expect( - (await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) => - p.startsWith('app'), - ), - ).toEqual(['app']); - }, 60000); + it( + 'should correctly merge two identical app folders', + async () => { + const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); + await makeUniversalApp({ + x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'), + arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), + outAppPath: out, + }); + await verifyApp(out); + }, + VERIFY_APP_TIMEOUT, + ); - it.todo('should shim two different app folders'); + it( + 'should shim two different app folders', + async () => { + const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { + const { testPath } = await createTestApp('shimArm64', { + 'i-aint-got-no-rhythm.bin': 'boomshakalaka', + }); + await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); + }); + + const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { + const { testPath } = await createTestApp('shimX64', { 'hello-world.bin': 'Hello World' }); + await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); + }); + + const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app'); + await makeUniversalApp({ + x64AppPath, + arm64AppPath, + outAppPath, + }); + await verifyApp(outAppPath); + }, + VERIFY_APP_TIMEOUT, + ); }); // TODO: Add tests for diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 0000000..03a9d6d --- /dev/null +++ b/test/util.ts @@ -0,0 +1,180 @@ +import { downloadArtifact } from '@electron/get'; +import { spawn } from '@malept/cross-spawn-promise'; +import * as zip from 'cross-zip'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import plist from 'plist'; +import * as fileUtils from '../dist/cjs/file-utils'; +import { getRawHeader } from '@electron/asar'; + +// We do a LOT of verifications in `verifyApp` 😅 +// exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries +// plus some tests create fixtures at runtime +export const VERIFY_APP_TIMEOUT = 80 * 1000; + +export const asarsDir = path.resolve(__dirname, 'fixtures', 'asars'); +export const appsDir = path.resolve(__dirname, 'fixtures', 'apps'); + +export const verifyApp = async (appPath: string) => { + await ensureUniversal(appPath); + + const resourcesDir = path.resolve(appPath, 'Contents', 'Resources'); + const resourcesDirContents = await fs.readdir(resourcesDir); + + // sort for consistent result + const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort(); + for await (const asar of asars) { + // verify header + const asarFs = getRawHeader(path.resolve(resourcesDir, asar)); + expect(removeUnstableProperties(asarFs.header)).toMatchSnapshot(); + } + + // check all app and unpacked dirs (incl. shimmed) + const dirsToSnapshot = [ + 'app', + 'app.asar.unpacked', + 'app-x64', + 'app-x64.asar.unpacked', + 'app-arm64', + 'app-arm64.asar.unpacked', + ]; + const appDirs = resourcesDirContents + .filter((p) => dirsToSnapshot.includes(path.basename(p))) + .sort(); + for await (const dir of appDirs) { + await verifyFileTree(path.resolve(resourcesDir, dir)); + } + + const allFiles = await fileUtils.getAllAppFiles(appPath); + const infoPlists = allFiles + .filter( + (appFile) => + appFile.type === fileUtils.AppFileType.INFO_PLIST && + // These are test app fixtures, no need to snapshot within `TestApp.app/Contents/Frameworks` + !appFile.relativePath.includes(path.join('Contents', 'Frameworks')), + ) + .map((af) => af.relativePath) + .sort(); + + const integrityMap: Record = {}; + const integrity = await Promise.all( + infoPlists.map((ip) => extractAsarIntegrity(path.resolve(appPath, ip))), + ); + for (let i = 0; i < integrity.length; i++) { + const relativePath = infoPlists[i]; + const asarIntegrity = integrity[i]; + integrityMap[relativePath] = asarIntegrity; + } + expect(integrityMap).toMatchSnapshot(); +}; + +// note: `infoPlistsToIgnore` will not have integrity in sub-app plists +const extractAsarIntegrity = async (infoPlist: string) => { + const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse( + await fs.readFile(infoPlist, 'utf-8'), + ) as any; + return integrity; +}; + +export const verifyFileTree = async (dirPath: string) => { + const dirFiles = await fileUtils.getAllAppFiles(dirPath); + const files = dirFiles.map((file) => { + const it = path.join(dirPath, file.relativePath); + const name = toSystemIndependentPath(file.relativePath); + if (it.endsWith('.txt') || it.endsWith('.json')) { + return { name, content: fs.readFileSync(it, 'utf-8') }; + } + return name; + }); + expect(files).toMatchSnapshot(); +}; + +export const ensureUniversal = async (app: string) => { + const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); + const result = await spawn(exe); + expect(result).toContain('arm64'); + const result2 = await spawn('arch', ['-x86_64', exe]); + expect(result2).toContain('x64'); +}; + +export const toSystemIndependentPath = (s: string): string => { + return path.sep === '/' ? s : s.replace(/\\/g, '/'); +}; + +export const removeUnstableProperties = (data: any) => { + return JSON.parse( + JSON.stringify(data, (name, value) => { + if (name === 'offset') { + return undefined; + } + return value; + }), + ); +}; + +/** + * Directory structure: + * testName + * ├── private + * │ └── var + * │ ├── app + * │ │ └── file.txt -> ../file.txt + * │ └── file.txt + * └── var -> private/var + * ├── index.js + * ├── package.json + */ +export const createTestApp = 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.remove(testPath); + + await fs.copy(path.join(asarsDir, 'app'), testPath); + + const privateVarPath = path.join(testPath, 'private', 'var'); + const varPath = path.join(testPath, 'var'); + + await fs.mkdir(privateVarPath, { recursive: true }); + await fs.symlink(path.relative(testPath, privateVarPath), varPath); + + const files = { + 'file.txt': 'hello world', + ...additionalFiles, + }; + for await (const [filename, fileData] of Object.entries(files)) { + const originFilePath = path.join(varPath, filename); + await fs.writeFile(originFilePath, fileData); + } + const appPath = path.join(varPath, 'app'); + await fs.mkdirp(appPath); + await fs.symlink('../file.txt', path.join(appPath, 'file.txt')); + + return { + testPath, + varPath, + appPath, + }; +}; + +export const templateApp = async ( + name: string, + arch: string, + modify: (appPath: string) => Promise, +) => { + const electronZip = await downloadArtifact({ + artifactName: 'electron', + version: '27.0.0', + platform: 'darwin', + arch, + }); + const appPath = path.resolve(appsDir, name); + zip.unzipSync(electronZip, appsDir); + await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath); + await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar')); + await modify(appPath); + + return appPath; +};