Files
hermes-agent/apps/desktop/scripts/mac-sign.test.mjs
ethernet 427ab1d58d test(desktop): require HERMES_PYTHON for mac-sign digest tests
The fixture imports pm and scripts.bundles, which only the prepared
runtime provides. Falling back to py/python3 on PATH failed later with
an unrelated ImportError; fail up front with a clear message instead.
2026-09-24 14:08:22 -04:00

172 lines
7.9 KiB
JavaScript

import assert from 'node:assert/strict'
import childProcess from 'node:child_process'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { Arch, MacPackager } from 'app-builder-lib'
import { MacTargetHelper } from 'app-builder-lib/internal'
import { test, vi } from 'vitest'
const require = createRequire(import.meta.url)
const config = require('../electron-builder.config.cjs')
const repo = path.resolve(import.meta.dirname, '../../..')
const desktop = path.resolve(import.meta.dirname, '..')
const digestModule = pathToFileURL(path.join(import.meta.dirname, 'payload-digests.mjs')).href
// The fixture imports pm and scripts.bundles, which only the prepared runtime
// provides; a PATH python would fail later with an unrelated ImportError.
function preparedPython() {
const configured = process.env.HERMES_PYTHON
if (!configured) throw new Error('mac-sign tests need HERMES_PYTHON set to the prepared Hermes runtime interpreter')
return configured
}
function fixture() {
const python = preparedPython()
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-digest-order-'))
const app = path.join(root, 'Hermes.app')
const payload = path.join(app, 'Contents', 'Resources', 'agent-payload')
const tools = path.join(payload, 'tools')
const nested = path.join(tools, 'chromium', 'Browser.app')
const binaries = [path.join(app, 'Contents', 'MacOS', 'Hermes'),
path.join(tools, 'python', 'bin', 'python3'),
path.join(nested, 'Contents', 'MacOS', process.platform === 'win32' ? 'chrome.exe' : 'Chromium')]
for (const binary of binaries) {
fs.mkdirSync(path.dirname(binary), { recursive: true })
const bytes = Buffer.alloc(64)
bytes.writeUInt32LE(0xfeedfacf)
bytes.writeUInt32LE(0x100000c, 4)
fs.writeFileSync(binary, bytes)
}
const ignored = path.join(tools, 'python', 'non-macho.bin')
fs.writeFileSync(ignored, Buffer.alloc(64))
const env = { ...process.env, HERMES_HOME: path.join(root, 'home'),
HERMES_RUNTIME_DIR: path.join(root, 'state'), HERMES_PYTHON: python,
UV_OFFLINE: '1', UV_PYTHON_DOWNLOADS: 'never', UV_CACHE_DIR: path.join(root, 'cache') }
delete env.PYTHONHOME
delete env.PYTHONPATH
const py = code => childProcess.execFileSync(python, ['-c', code, payload], {
cwd: repo, env, encoding: 'utf8', timeout: 60000
}).trim()
// The digest hook only needs the manifest to mark the payload as present.
fs.writeFileSync(path.join(payload, 'manifest.json'), '{}')
py('from pathlib import Path; import sys; from scripts.bundles.payload import record_tools; from pm.store import current_target; root=Path(sys.argv[1]); record_tools(root,Path("pm/lock.json"),current_target(),{"python":"python","chromium":"chromium"})')
const facts = path.join(tools, 'facts.json')
const initial = JSON.parse(fs.readFileSync(facts, 'utf8'))
return { root, app, payload, tools, nested, binaries, ignored, env, py, facts, initial }
}
function rehashInSubprocess(f) {
return childProcess.spawnSync(process.execPath, ['--input-type=module', '-e',
`import { rehashPayloadDigests } from ${JSON.stringify(digestModule)}; rehashPayloadDigests(process.argv[1])`,
f.payload], { cwd: repo, env: f.env, encoding: 'utf8', timeout: 60000 })
}
async function signThroughBuilder(f) {
const packager = {
config,
appInfo: { type: 'module' },
info: { getWorkspaceRoot: async () => repo },
buildResourcesDir: path.join(desktop, 'build'),
resourceList: Promise.resolve([]),
getResource: async value => path.resolve(desktop, value),
expandArch: value => [value]
}
packager.helper = new MacTargetHelper(packager)
const identity = { name: 'Developer ID Application: Test Fixture (ABC123)' }
const declared = typeof config.mac.sign === 'object' ? config.mac.sign : undefined
const opts = await packager.helper.buildSignOptions(
f.app, identity, declared, path.join(f.root, 'fixture.keychain-db'), Arch.arm64, 'mac')
// This is the builder's real dispatch, not a test-only invocation of a factory.
await MacPackager.prototype.doSign.call(packager, opts, config.mac.sign, identity)
}
function armEnvironment(f) {
for (const name of ['HERMES_HOME', 'HERMES_RUNTIME_DIR', 'HERMES_PYTHON', 'UV_OFFLINE', 'UV_PYTHON_DOWNLOADS', 'UV_CACHE_DIR']) {
vi.stubEnv(name, f.env[name])
}
vi.stubEnv('PYTHONHOME', undefined)
vi.stubEnv('PYTHONPATH', undefined)
}
test('builder dispatch refreshes real facts after every payload signature and before the outer seal', async () => {
const f = fixture()
const signed = []
const commands = []
let outerFacts
let digestsAtSeal
armEnvironment(f)
// Execute the installed walk and argument builders; intercept only native codesign.
const native = vi.spyOn(childProcess, 'execFile').mockImplementation((file, args, options, callback) => {
try {
assert.equal(file, 'codesign', 'no other native signing or keychain command is permitted')
if (args.includes('--sign')) {
const target = args.at(-1)
commands.push(args)
if (target === f.app) {
digestsAtSeal = JSON.parse(f.py('from pathlib import Path; import sys,json; from pm.store import tree_digest; root=Path(sys.argv[1]); print(json.dumps({name:tree_digest(root/"tools"/name) for name in ("python","chromium")}))'))
outerFacts = JSON.parse(fs.readFileSync(f.facts, 'utf8'))
} else if (fs.statSync(target).isDirectory()) {
const signature = path.join(target, 'Contents', '_CodeSignature', 'CodeResources')
fs.mkdirSync(path.dirname(signature), { recursive: true })
fs.writeFileSync(signature, 'fixture byte mutation, not a native signature')
} else {
fs.appendFileSync(target, 'fixture byte mutation, not a native signature')
}
signed.push(target)
}
callback(null, '', '')
} catch (error) {
callback(error, '', '')
}
return undefined
})
try {
await signThroughBuilder(f)
assert.equal(signed.at(-1), f.app)
assert.deepEqual(new Set(signed.slice(0, -1)), new Set([...f.binaries, f.nested]))
assert.ok(!signed.includes(f.ignored))
for (const args of commands) {
assert.equal(args[args.indexOf('--sign') + 1], 'Developer ID Application: Test Fixture (ABC123)')
assert.equal(args[args.indexOf('--keychain') + 1], path.join(f.root, 'fixture.keychain-db'))
assert.equal(path.resolve(args[args.indexOf('--entitlements') + 1]),
path.join(desktop, 'electron', args.at(-1) === f.app ? 'entitlements.mac.plist' : 'entitlements.mac.inherit.plist'))
if (process.platform === 'darwin') assert.ok(args.includes('--options') && args[args.indexOf('--options') + 1].includes('runtime'))
}
for (const name of Object.keys(digestsAtSeal)) {
assert.notEqual(digestsAtSeal[name], f.initial.packages[name].digest)
assert.deepEqual(outerFacts.packages[name], { ...f.initial.packages[name], digest: digestsAtSeal[name] })
}
} finally {
native.mockRestore()
vi.unstubAllEnvs()
fs.rmSync(f.root, { recursive: true, force: true })
}
}, 30000)
test('payload digest refresh rejects corrupt and missing PM facts but skips payload-free builds', () => {
const f = fixture()
try {
fs.writeFileSync(f.facts, 'invalid-json')
const corrupt = rehashInSubprocess(f)
assert.notEqual(corrupt.status, 0)
assert.match(corrupt.stderr, /cannot read recorded package state/)
assert.equal(fs.readFileSync(f.facts, 'utf8'), 'invalid-json')
fs.unlinkSync(f.facts)
const missing = rehashInSubprocess(f)
assert.notEqual(missing.status, 0)
assert.match(missing.stderr, /payload facts carry no tool entries/)
assert.equal(fs.existsSync(f.facts), false)
fs.rmSync(f.payload, { recursive: true })
const light = rehashInSubprocess(f)
assert.equal(light.status, 0, light.stdout + light.stderr)
assert.equal(fs.existsSync(f.payload), false)
} finally {
fs.rmSync(f.root, { recursive: true, force: true })
}
}, 30000)