From 0ae6da4b3e5e7f9693551cfd76ce27332e4250b4 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Thu, 30 Apr 2026 23:22:05 +0200 Subject: [PATCH 1/2] Add macOS DMG integration tests --- .github/workflows/ci.yml | 17 +++++ lib/darwin/hdiutil.js | 19 +++-- package.json | 8 +- test/integration/dmg.test.js | 142 +++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 test/integration/dmg.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f2e31e..b5f5809 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,20 @@ jobs: - run: node --check lib/render-progress.js - run: node --check test/args.test.js - run: node --check test/main.test.js + + integration: + name: integration node v24 macos-15-intel + runs-on: macos-15-intel + needs: + - appdmg + - cli + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test:integration + - run: node --check test/integration/dmg.test.js diff --git a/lib/darwin/hdiutil.js b/lib/darwin/hdiutil.js index 03d4f02..ec0b87b 100644 --- a/lib/darwin/hdiutil.js +++ b/lib/darwin/hdiutil.js @@ -34,10 +34,7 @@ async function attach (commands, imagePath) { ] const result = await commands.run('hdiutil', args) - const mountPath = result.stdout - .split('\n') - .map((line) => line.trim().split(/\s+/).pop()) - .find((value) => value && path.isAbsolute(value)) + const mountPath = parseMountPath(result.stdout) if (!mountPath) { throw new Error('Failed to mount image') @@ -46,6 +43,17 @@ async function attach (commands, imagePath) { return mountPath } +function parseMountPath (stdout) { + for (const line of stdout.split('\n')) { + const volumeMatch = /(\/Volumes\/.+)$/.exec(line) + if (volumeMatch) return volumeMatch[1] + + const tabParts = line.trim().split('\t') + const lastTabPart = tabParts[tabParts.length - 1] + if (lastTabPart && path.isAbsolute(lastTabPart)) return lastTabPart + } +} + async function detach (commands, mountPath) { const args = ['detach', mountPath] @@ -91,5 +99,6 @@ module.exports = { attach, convert, create, - detach + detach, + parseMountPath } diff --git a/package.json b/package.json index 7cf4973..a440e05 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "node": ">=24" }, "scripts": { - "test": "ava" + "test": "ava test/*.test.js", + "test:integration": "ava test/integration/**/*.test.js" }, "dependencies": { "@appdmg/ds-store": "~1.0.0", @@ -47,10 +48,5 @@ "overrides": { "js-yaml": "~3.14.2", "lodash": "~4.18.1" - }, - "ava": { - "files": [ - "test/**/*.test.js" - ] } } diff --git a/test/integration/dmg.test.js b/test/integration/dmg.test.js new file mode 100644 index 0000000..d70bf3d --- /dev/null +++ b/test/integration/dmg.test.js @@ -0,0 +1,142 @@ +'use strict' + +const { execFile } = require('node:child_process') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { promisify } = require('node:util') +const test = require('ava').default + +const appdmg = require('../..') +const { parseMountPath } = require('../../lib/darwin/hdiutil') + +const execFileAsync = promisify(execFile) +const macOSTest = process.platform === 'darwin' ? test : test.skip + +macOSTest('creates and inspects a modern DMG specification', async (t) => { + const tmpDir = await makeTempDir(t) + const target = path.join(tmpDir, 'modern.dmg') + + await waitForImage(appdmg({ + source: assetPath('appdmg.json'), + target + })) + + t.is(await imageFormat(target), 'UDZO') + + await withMountedImage(target, async (mountPath) => { + t.regex(path.basename(mountPath), /^Test Title( \d+)?$/) + t.true((await fs.stat(path.join(mountPath, 'TestApp.app'))).isDirectory()) + t.is(await fs.readFile(path.join(mountPath, 'TestDoc.txt'), 'utf8'), 'This is just a test.\n') + t.is(await fs.readlink(path.join(mountPath, 'Applications')), '/Applications') + t.true((await fs.stat(path.join(mountPath, '.background', 'TestBkg.tiff'))).isFile()) + t.true((await fs.stat(path.join(mountPath, '.DS_Store'))).isFile()) + t.true((await fs.stat(path.join(mountPath, '.VolumeIcon.icns'))).isFile()) + }) +}) + +macOSTest('keeps legacy JSON input support', async (t) => { + const tmpDir = await makeTempDir(t) + const target = path.join(tmpDir, 'legacy.dmg') + + await waitForImage(appdmg({ + source: assetPath('appdmg-legacy.json'), + target + })) + + t.is(await imageFormat(target), 'UDZO') + + await withMountedImage(target, async (mountPath) => { + t.true((await fs.stat(path.join(mountPath, 'TestApp.app'))).isDirectory()) + t.is(await fs.readlink(path.join(mountPath, 'Applications')), '/Applications') + }) +}) + +macOSTest('creates a DMG with custom names and explicit format', async (t) => { + const tmpDir = await makeTempDir(t) + const target = path.join(tmpDir, 'custom.dmg') + + await waitForImage(appdmg({ + target, + basepath: assetPath('.'), + specification: { + title: 'Custom Names', + format: 'UDRO', + background: 'TestBkg.png', + contents: [ + { x: 448, y: 344, type: 'link', path: '/Applications', name: 'System Apps' }, + { x: 192, y: 344, type: 'file', path: 'TestApp.app', name: 'My Nice App.app' }, + { x: 512, y: 128, type: 'file', path: 'TestDoc.txt', name: 'Documentation.txt' } + ] + } + })) + + t.is(await imageFormat(target), 'UDRO') + + await withMountedImage(target, async (mountPath) => { + t.is(await fs.readlink(path.join(mountPath, 'System Apps')), '/Applications') + t.true((await fs.stat(path.join(mountPath, 'My Nice App.app'))).isDirectory()) + t.is(await fs.readFile(path.join(mountPath, 'Documentation.txt'), 'utf8'), 'This is just a test.\n') + t.true((await fs.stat(path.join(mountPath, '.background', 'TestBkg.tiff'))).isFile()) + t.true((await fs.stat(path.join(mountPath, '.DS_Store'))).isFile()) + }) +}) + +function waitForImage (image) { + return new Promise((resolve, reject) => { + image.once('finish', resolve) + image.once('error', reject) + }) +} + +async function imageFormat (imagePath) { + const result = await execFileAsync('hdiutil', ['imageinfo', '-format', imagePath]) + return result.stdout.trim() +} + +async function withMountedImage (imagePath, fn) { + const mountPath = await attach(imagePath) + + try { + await fn(mountPath) + } finally { + await detach(mountPath) + } +} + +async function attach (imagePath) { + const result = await execFileAsync('hdiutil', [ + 'attach', + imagePath, + '-readonly', + '-nobrowse', + '-noverify', + '-noautoopen' + ]) + + const mountPath = parseMountPath(result.stdout) + + if (!mountPath) { + throw new Error('Failed to mount image during integration test') + } + + return mountPath +} + +async function detach (mountPath) { + await execFileAsync('hdiutil', ['detach', mountPath]) +} + +function assetPath (name) { + return path.join(__dirname, '..', 'assets', name) +} + +async function makeTempDir (t) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'appdmg-integration-')) + + t.teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + return tmpDir +} From 00b0fd0673d126dc40e3fa58a653e68133943f82 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Thu, 30 Apr 2026 23:25:42 +0200 Subject: [PATCH 2/2] Stabilize integration test runtime --- package.json | 2 +- test/integration/dmg.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a440e05..7b1a36f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "scripts": { "test": "ava test/*.test.js", - "test:integration": "ava test/integration/**/*.test.js" + "test:integration": "ava --timeout=2m --serial test/integration/**/*.test.js" }, "dependencies": { "@appdmg/ds-store": "~1.0.0", diff --git a/test/integration/dmg.test.js b/test/integration/dmg.test.js index d70bf3d..06575da 100644 --- a/test/integration/dmg.test.js +++ b/test/integration/dmg.test.js @@ -11,7 +11,7 @@ const appdmg = require('../..') const { parseMountPath } = require('../../lib/darwin/hdiutil') const execFileAsync = promisify(execFile) -const macOSTest = process.platform === 'darwin' ? test : test.skip +const macOSTest = process.platform === 'darwin' ? test.serial : test.skip macOSTest('creates and inspects a modern DMG specification', async (t) => { const tmpDir = await makeTempDir(t)