Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 14 additions & 5 deletions lib/darwin/hdiutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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]

Expand Down Expand Up @@ -91,5 +99,6 @@ module.exports = {
attach,
convert,
create,
detach
detach,
parseMountPath
}
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"node": ">=24"
},
"scripts": {
"test": "ava"
"test": "ava test/*.test.js",
"test:integration": "ava --timeout=2m --serial test/integration/**/*.test.js"
},
"dependencies": {
"@appdmg/ds-store": "~1.0.0",
Expand All @@ -47,10 +48,5 @@
"overrides": {
"js-yaml": "~3.14.2",
"lodash": "~4.18.1"
},
"ava": {
"files": [
"test/**/*.test.js"
]
}
}
142 changes: 142 additions & 0 deletions test/integration/dmg.test.js
Original file line number Diff line number Diff line change
@@ -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.serial : 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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry hdiutil detach in integration cleanup

The integration helper does a single hdiutil detach attempt, but macOS often returns transient Resource busy (exit code 16) immediately after file reads; this repo’s production detach path already handles that with retries (lib/darwin/hdiutil.js). In CI, a transient busy mount will make these integration tests fail even when DMG generation is correct, so cleanup should mirror the retry/backoff behavior to avoid flaky failures.

Useful? React with 👍 / 👎.

}

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
}