Files
openclaw/test/scripts/create-dmg.test.ts
T

515 lines
19 KiB
TypeScript

// Create Dmg tests cover create dmg script behavior.
import { spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
const scriptPath = "scripts/create-dmg.sh";
function makeApp(plistEntries: string[]): string {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-"));
tempDirs.push(dir);
const app = path.join(dir, "OpenClaw.app");
const contents = path.join(app, "Contents");
mkdirSync(contents, { recursive: true });
writeFileSync(
path.join(contents, "Info.plist"),
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
"<dict>",
...plistEntries,
"</dict>",
"</plist>",
"",
].join("\n"),
"utf8",
);
return app;
}
function makeValidApp(): string {
return makeApp([
"<key>CFBundleName</key>",
"<string>OpenClaw</string>",
"<key>CFBundleShortVersionString</key>",
"<string>2026.6.16</string>",
]);
}
function makeFakeDmgTools() {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-tools-"));
tempDirs.push(dir);
const bin = path.join(dir, "bin");
const hdiutilLog = path.join(dir, "hdiutil.log");
const osascriptLog = path.join(dir, "osascript.applescript");
mkdirSync(bin, { recursive: true });
const hdiutil = path.join(bin, "hdiutil");
writeFileSync(
hdiutil,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$HDIUTIL_LOG"
command_name="\${1:-}"
shift || true
if [[ "\${HDIUTIL_FAIL_ON:-}" == "$command_name" ]]; then
exit 17
fi
case "$command_name" in
create)
: > "\${!#}"
;;
attach)
mountpoint=""
while (($#)); do
if [[ "$1" == "-mountpoint" ]]; then
mountpoint="$2"
break
fi
shift
done
if [[ "\${HDIUTIL_ATTACH_MARKER:-0}" == "1" && -n "$mountpoint" ]]; then
mkdir -p "$mountpoint"
printf mounted > "$mountpoint/live-volume-file"
fi
printf '%s' "$mountpoint" > "\${HDIUTIL_LOG}.attached"
printf '/dev/disk99\tGUID_partition_scheme\t\n/dev/disk99s1\tApple_HFS\t%s\n' "$mountpoint"
;;
detach)
if [[ "\${HDIUTIL_DETACH_FAIL:-0}" == "1" ]]; then
exit 9
fi
detach_attempts_file="\${HDIUTIL_LOG}.detach-attempts"
detach_attempts=0
if [[ -f "$detach_attempts_file" ]]; then
detach_attempts="$(cat "$detach_attempts_file")"
fi
detach_attempts=$((detach_attempts + 1))
printf '%s' "$detach_attempts" > "$detach_attempts_file"
vanish_at="\${HDIUTIL_DETACH_VANISH_AT:-0}"
if (( vanish_at > 0 && detach_attempts >= vanish_at )); then
rm -f "\${HDIUTIL_LOG}.attached"
echo "hdiutil: detach failed - No such file or directory" >&2
exit 1
fi
if (( detach_attempts <= \${HDIUTIL_DETACH_FAIL_COUNT:-0} )); then
exit 9
fi
rm -f "\${HDIUTIL_LOG}.attached"
;;
resize)
if [[ "\${1:-}" == "-limits" ]]; then
printf '100 200 300\\n'
fi
;;
convert)
output=""
while (($#)); do
if [[ "$1" == "-o" ]]; then
output="$2"
break
fi
shift
done
[[ -n "$output" ]]
printf 'converted' > "$output"
;;
verify)
[[ -f "\${1:-}" ]]
;;
esac
`,
"utf8",
);
chmodSync(hdiutil, 0o755);
const osascript = path.join(bin, "osascript");
writeFileSync(osascript, '#!/usr/bin/env bash\ncat > "$OSASCRIPT_LOG"\nexit 0\n', "utf8");
chmodSync(osascript, 0o755);
for (const command of ["sleep", "sync"]) {
const tool = path.join(bin, command);
writeFileSync(tool, `#!/bin/bash\nprintf '${command} %s\\n' "$*" >> "$HDIUTIL_LOG"\n`, "utf8");
chmodSync(tool, 0o755);
}
const mount = path.join(bin, "mount");
writeFileSync(
mount,
`#!/bin/bash
echo '/dev/disk1s1 on / (apfs, sealed, local, read-only, journaled)'
[[ -f "$HDIUTIL_LOG.attached" ]] && echo "/dev/disk99s1 on $(cat "$HDIUTIL_LOG.attached") (hfs, local, nobrowse)"
exit 0
`,
"utf8",
);
chmodSync(mount, 0o755);
return {
env: {
HDIUTIL_LOG: hdiutilLog,
OSASCRIPT_LOG: osascriptLog,
PATH: `${bin}:${process.env.PATH ?? ""}`,
SKIP_DMG_STYLE: "1",
},
hdiutilLog,
osascriptLog,
};
}
function runScript(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync("/bin/bash", [scriptPath, ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...env },
});
}
function readPngDimensions(imagePath: string): { width: number; height: number } {
const data = readFileSync(imagePath);
expect(data.subarray(1, 4).toString("ascii")).toBe("PNG");
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
}
function expectPrivateDmgMount(log: string): string {
const attach = log.match(/^attach (.+)\/image-rw\.dmg -mountpoint (.+) -nobrowse$/m);
const runRoot = attach?.[1];
const mountPoint = attach?.[2];
if (!runRoot || !mountPoint) {
throw new Error("Expected a DMG attach command with image and mount paths");
}
// TMPDIR may itself live under /Volumes; ownership comes from the per-run root.
expect(path.relative(tmpdir(), runRoot)).toMatch(/^openclaw-dmg\.[^/]+$/);
expect(mountPoint).toBe(path.join(runRoot, "mount"));
const detachTargets = Array.from(
log.matchAll(/^detach (.+?)(?: -(?:quiet|force))?$/gm),
([, target]) => target,
);
expect(detachTargets).toContain(mountPoint);
expect(detachTargets.filter((target) => target !== "/dev/disk99s1")).toEqual(
detachTargets.filter((target) => target === mountPoint),
);
return mountPoint;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("create-dmg plist validation", () => {
it("fails closed for required Info.plist reads", () => {
const script = readFileSync(scriptPath, "utf8");
const readBlock = script.slice(
script.indexOf("APP_NAME="),
script.indexOf('DMG_NAME="${APP_NAME}-${VERSION}.dmg"'),
);
expect(script).toContain('source "$ROOT_DIR/scripts/lib/plistbuddy.sh"');
expect(readBlock).toContain(
'APP_NAME="$(plist_print_required "$APP_PATH/Contents/Info.plist" CFBundleName)"',
);
expect(readBlock).toContain(
'VERSION="$(plist_print_required "$APP_PATH/Contents/Info.plist" CFBundleShortVersionString)"',
);
expect(readBlock).not.toContain("PlistBuddy");
expect(readBlock).not.toContain("|| echo");
});
it("keeps temporary DMG artifacts scoped to one run", () => {
const script = readFileSync(scriptPath, "utf8");
expect(script).toContain('DMG_TEMP="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-dmg.XXXXXX")"');
expect(script).toContain('DMG_SOURCE="$DMG_TEMP/source"');
expect(script).toContain('MOUNT_POINT="$DMG_TEMP/mount"');
expect(script).toContain('DMG_RW_PATH="$DMG_TEMP/image-rw.dmg"');
expect(script).toContain('DMG_OUTPUT_TEMP=""');
expect(script).toContain('DMG_FINAL_PATH=""');
expect(script).toContain(
'DMG_OUTPUT_TEMP="$(mktemp -d "$(dirname "$OUT_PATH")/.openclaw-dmg.XXXXXX")"',
);
expect(script).toContain('DMG_FINAL_PATH="$DMG_OUTPUT_TEMP/final.dmg"');
expect(script).toContain('DMG_LIMITS_PATH="$DMG_TEMP/resize-limits.txt"');
expect(script).toContain('hdiutil resize -limits "$DMG_RW_PATH" >"$DMG_LIMITS_PATH"');
expect(script).not.toContain("/tmp/openclaw-dmg-limits.txt");
expect(script).not.toContain('"/Volumes/$DMG_VOLUME_NAME"');
expect(script).not.toContain('tell application "Finder" to close every window');
});
it("keeps the larger Finder layout aligned with the packaged backgrounds", () => {
const script = readFileSync(scriptPath, "utf8");
expect(script).toContain('DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 1080 530}"');
expect(script).toContain('DMG_ICON_SIZE="${DMG_ICON_SIZE:-144}"');
expect(script).toContain('DMG_APP_POS="${DMG_APP_POS:-170 305}"');
expect(script).toContain('DMG_APPS_POS="${DMG_APPS_POS:-510 305}"');
expect(readPngDimensions("apps/macos/Packaging/dmg-background-small.png")).toEqual({
width: 680,
height: 430,
});
expect(readPngDimensions("apps/macos/Packaging/dmg-background.png")).toEqual({
width: 1360,
height: 860,
});
});
it("fails malformed DMG resize slack before creating images", () => {
const script = readFileSync(scriptPath, "utf8");
const validationBlock = script.slice(
script.indexOf("require_integer_list()"),
script.indexOf("to_applescript_list4()"),
);
expect(validationBlock).toContain("require_nonnegative_integer()");
expect(validationBlock).toContain(
'require_nonnegative_integer DMG_EXTRA_SECTORS "$DMG_EXTRA_SECTORS"',
);
expect(validationBlock).toContain("must be a finite non-negative integer");
});
it.runIf(process.platform === "darwin")(
"fails before hdiutil when required plist keys are missing",
() => {
const app = makeApp(["<key>CFBundleName</key>", "<string>OpenClaw</string>"]);
const result = runScript([app, path.join(path.dirname(app), "out.dmg")]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Does Not Exist");
expect(result.stdout).not.toContain("Creating DMG:");
},
);
});
describe.runIf(process.platform === "darwin")("create-dmg ownership boundaries", () => {
it("uses private intermediate paths without deleting caller-owned siblings", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const sibling = path.join(outputDir, "OpenClaw-rw.dmg");
writeFileSync(output, "previous output", "utf8");
writeFileSync(sibling, "caller owned", "utf8");
const tools = makeFakeDmgTools();
const result = runScript([app, output], tools.env);
expect(result.status).toBe(0);
expect(readFileSync(output, "utf8")).toBe("converted");
expect(readFileSync(sibling, "utf8")).toBe("caller owned");
const log = readFileSync(tools.hdiutilLog, "utf8");
expectPrivateDmgMount(log);
expect(log).toContain("convert ");
// hdiutil sizes -srcfolder images with filesystem overhead; an explicit
// byte estimate underallocates bundles containing many small files.
const create = log.split("\n").find((line) => line.startsWith("create "));
expect(create).toContain("-srcfolder ");
expect(create).not.toMatch(/\s-(?:size|megabytes|sectors)\s/);
expect(log).toContain("final.dmg");
expect(log).toContain(`${outputDir}${path.sep}.openclaw-dmg.`);
expect(log).not.toContain(sibling);
});
it("creates a caller-provided output directory before finalizing the DMG", () => {
const app = makeValidApp();
const root = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(root);
const outputDir = path.join(root, "nested", "artifacts");
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], tools.env);
expect(result.status).toBe(0);
expect(existsSync(outputDir)).toBe(true);
expect(readFileSync(output, "utf8")).toBe("converted");
const log = readFileSync(tools.hdiutilLog, "utf8");
expect(log).toContain(`${outputDir}${path.sep}.openclaw-dmg.`);
});
it("preserves an existing output when image creation fails", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
writeFileSync(output, "previous output", "utf8");
const tools = makeFakeDmgTools();
const result = runScript([app, output], { ...tools.env, HDIUTIL_FAIL_ON: "create" });
expect(result.status).not.toBe(0);
expect(readFileSync(output, "utf8")).toBe("previous output");
expect(readFileSync(tools.hdiutilLog, "utf8")).not.toContain("detach");
});
it("fails before image creation when Finder layout values are malformed", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], {
...tools.env,
DMG_WINDOW_BOUNDS: "400 nope 900 420",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("DMG_WINDOW_BOUNDS must contain only integer values");
expect(existsSync(output)).toBe(false);
expect(existsSync(tools.hdiutilLog) ? readFileSync(tools.hdiutilLog, "utf8") : "").toBe("");
});
it("fails before image creation when Finder layout values span multiple lines", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], {
...tools.env,
DMG_WINDOW_BOUNDS: "400 100 900 420\nnope",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("DMG_WINDOW_BOUNDS must be a single line");
expect(existsSync(output)).toBe(false);
expect(existsSync(tools.hdiutilLog) ? readFileSync(tools.hdiutilLog, "utf8") : "").toBe("");
});
it("preserves an existing output when verification fails", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
writeFileSync(output, "previous output", "utf8");
const tools = makeFakeDmgTools();
const result = runScript([app, output], { ...tools.env, HDIUTIL_FAIL_ON: "verify" });
expect(result.status).not.toBe(0);
expect(readFileSync(output, "utf8")).toBe("previous output");
const log = readFileSync(tools.hdiutilLog, "utf8");
expect(log).toContain("convert ");
expect(log).toContain("final.dmg");
});
it("fails before resize and conversion when its private mount cannot detach", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], {
...tools.env,
HDIUTIL_ATTACH_MARKER: "1",
HDIUTIL_DETACH_FAIL: "1",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Failed to detach DMG mount");
expect(result.stderr).toContain("Preserving DMG temp root");
const log = readFileSync(tools.hdiutilLog, "utf8");
const mountPoint = expectPrivateDmgMount(log);
expect(log).not.toContain("resize");
expect(log).not.toContain("convert");
// Nine polite attempts, three forced rounds by device then path, and the
// EXIT trap's last cleanup attempt.
expect(log.match(/^detach /gm)).toHaveLength(16);
expect(log.match(/^detach \/dev\/disk99s1 -force$/gm)).toHaveLength(4);
expect(log).toContain(`detach ${mountPoint} -force`);
expect(readFileSync(path.join(mountPoint, "live-volume-file"), "utf8")).toBe("mounted");
rmSync(path.dirname(mountPoint), { recursive: true, force: true });
});
it.each([
{ vanishAt: 5, detaches: 5, forced: false },
{ vanishAt: 10, detaches: 11, forced: true },
])(
"treats a busy volume that vanished at detach attempt $vanishAt as detached",
({ vanishAt, detaches, forced }) => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], {
...tools.env,
HDIUTIL_DETACH_FAIL_COUNT: "9",
HDIUTIL_DETACH_VANISH_AT: String(vanishAt),
});
expect(result.status).toBe(0);
expect(readFileSync(output, "utf8")).toBe("converted");
expect(result.stderr).not.toContain("Preserving DMG temp root");
const log = readFileSync(tools.hdiutilLog, "utf8");
const mountPoint = expectPrivateDmgMount(log);
expect(existsSync(path.dirname(mountPoint))).toBe(false);
const detachLines = log.split("\n").filter((line) => line.startsWith("detach "));
expect(detachLines).toHaveLength(detaches);
// The forced round tries the device node first, then the mount path.
expect(detachLines.filter((line) => line.endsWith("-force"))).toEqual(
forced ? ["detach /dev/disk99s1 -force", `detach ${mountPoint} -force`] : [],
);
expect(log).toContain("resize");
expect(log).toContain("convert ");
},
);
it.each([6, 9])("retries %i failed DMG detaches before finalizing the artifact", (failures) => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], {
...tools.env,
HDIUTIL_DETACH_FAIL_COUNT: String(failures),
});
expect(result.status).toBe(0);
expect(readFileSync(output, "utf8")).toBe("converted");
const log = readFileSync(tools.hdiutilLog, "utf8");
const detaches = log.split("\n").filter((line) => line.startsWith("detach "));
expect(detaches).toHaveLength(failures + 1);
expect(detaches.slice(0, -1).every((line) => line.endsWith("-quiet"))).toBe(true);
expect(detaches.at(-1)).toMatch(failures === 9 ? /-force$/ : /-quiet$/);
expect(log.indexOf("sync ")).toBeGreaterThan(log.indexOf("attach "));
expect(log.indexOf("sync ")).toBeLessThan(log.indexOf("sleep "));
const delays = Array.from(log.matchAll(/^sleep (\d+)$/gm), ([, delay]) => Number(delay));
expect(delays).toHaveLength(Math.min(failures + 1, 9));
expect(delays.reduce((sum, delay) => sum + delay, 0)).toBe(failures === 9 ? 54 : 35);
expect(log).toContain("resize");
expect(log).toContain("convert ");
});
it("styles the private mount without closing unrelated Finder windows", () => {
const app = makeValidApp();
const outputDir = mkdtempSync(path.join(tmpdir(), "openclaw-create-dmg-output-"));
tempDirs.push(outputDir);
const output = path.join(outputDir, "OpenClaw.dmg");
const tools = makeFakeDmgTools();
const result = runScript([app, output], { ...tools.env, SKIP_DMG_STYLE: "0" });
expect(result.status).toBe(0);
const applescript = readFileSync(tools.osascriptLog, "utf8");
expect(applescript).toContain('set dmgRoot to POSIX file "');
expect(applescript).toContain('/mount" as alias');
expect(applescript).toContain("set dmgDisk to disk of dmgRoot");
expect(applescript).not.toContain('tell disk "OpenClaw"');
expect(applescript).not.toContain("close every window");
});
});