Files
orca/config/scripts/build-mac-notarization-overlap.test.mjs
T

230 lines
7.6 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest'
import { buildWithOverlappingNotarization } from './build-mac-notarization-overlap.mjs'
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const context = (arch) => ({
arch,
electronPlatformName: 'darwin',
appOutDir: `dist/mac-${arch}`,
packager: { appInfo: { productFilename: 'Orca' } }
})
const signedConfig = () => ({
forceCodeSigning: true,
mac: { notarize: true, hardenedRuntime: true },
concurrency: { jobs: 8 },
publish: { releaseType: 'draft' }
})
const options = (build, notarize = vi.fn(async () => {}), verify = vi.fn()) => ({
config: signedConfig(),
build,
notarize,
verify,
targets: 'both-mac-targets',
publish: 'never',
credentials: { appleId: 'test' },
log: vi.fn()
})
// The real builder queues each artifact task without awaiting it in its serial pack loop.
function queuedBuilder(events, afterSign) {
return async ({ config, ...buildOptions }) => {
expect(buildOptions).toMatchObject({ targets: 'both-mac-targets', publish: 'never' })
expect(config.mac).toMatchObject({ notarize: false, hardenedRuntime: true })
expect(config.forceCodeSigning).toBe(true)
expect(config.concurrency.jobs).toBe(1)
const artifacts = []
for (const arch of [1, 3]) {
events.push(`pack:${arch}`)
await config.afterSign(context(arch))
artifacts.push(
(async () => {
await config.artifactBuildStarted({ arch })
events.push(`archive:${arch}`)
})()
)
await afterSign?.(arch)
}
await Promise.all(artifacts)
return ['x64.dmg', 'arm64.dmg']
}
}
describe('overlapping macOS notarization', () => {
it('reduces a modeled pair of 4-second Apple waits from 10 to 6 seconds', async () => {
vi.useFakeTimers()
try {
const started = Date.now()
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const build = async ({ config }) => {
const artifacts = []
for (const arch of [1, 3]) {
await delay(1000)
await config.afterSign(context(arch))
artifacts.push(config.artifactBuildStarted({ arch }))
}
await Promise.all(artifacts)
return []
}
const result = buildWithOverlappingNotarization(options(build, () => delay(4000)))
await vi.runAllTimersAsync()
await result
expect(Date.now() - started).toBe(6000)
expect(2 * (1000 + 4000)).toBe(10000)
} finally {
vi.useRealTimers()
}
})
it('packs serially, overlaps Apple waits, and gates every artifact on both verified tickets', async () => {
const events = []
const x64 = deferred(),
arm64 = deferred(),
bothSubmitted = deferred()
const notarize = vi.fn(({ appPath }) => {
events.push(`submit:${appPath}`)
if (appPath.includes('mac-3')) {
bothSubmitted.resolve()
}
return appPath.includes('mac-1') ? x64.promise : arm64.promise
})
const verify = vi.fn((appPath) => {
events.push(`verify:${appPath}`)
})
const result = buildWithOverlappingNotarization(
options(queuedBuilder(events), notarize, verify)
)
await bothSubmitted.promise
expect(events.filter((event) => event.startsWith('pack:'))).toEqual(['pack:1', 'pack:3'])
x64.resolve()
await vi.waitFor(() => expect(verify).toHaveBeenCalledTimes(1))
expect(events.some((event) => event.startsWith('archive:'))).toBe(false)
arm64.resolve()
await expect(result).resolves.toEqual(['x64.dmg', 'arm64.dmg'])
expect(events.findIndex((event) => event.startsWith('archive:'))).toBeGreaterThan(
events.findLastIndex((event) => event.startsWith('verify:'))
)
})
it.each([1, 3])('never archives when architecture %i notarization fails', async (failedArch) => {
const events = []
const failure = new Error('Apple rejected the app')
const notarize = vi.fn(async ({ appPath }) => {
if (appPath.includes(`mac-${failedArch}`)) {
throw failure
}
})
await expect(
buildWithOverlappingNotarization(options(queuedBuilder(events), notarize))
).rejects.toBe(failure)
expect(events.some((event) => event.startsWith('archive:'))).toBe(false)
})
it('never archives when signature or stapler validation fails', async () => {
const events = []
await expect(
buildWithOverlappingNotarization(
options(queuedBuilder(events), undefined, () => {
throw new Error('invalid staple')
})
)
).rejects.toThrow('invalid staple')
expect(events.some((event) => event.startsWith('archive:'))).toBe(false)
})
it('rejects waiting artifacts and drains the first notary if the second pack fails', async () => {
const waiting = deferred(),
started = deferred()
let artifactResult
const failure = new Error('second native rebuild failed')
const build = async ({ config }) => {
await config.afterSign(context(1))
artifactResult = config.artifactBuildStarted({ arch: 1 }).catch((error) => error)
throw failure
}
const result = buildWithOverlappingNotarization(
options(build, async () => {
started.resolve()
await waiting.promise
})
)
const settled = vi.fn()
result.catch(settled)
await started.promise
expect(await artifactResult).toBe(failure)
expect(settled).not.toHaveBeenCalled()
waiting.resolve()
await expect(result).rejects.toBe(failure)
})
it('does not mask a build failure with a later notary failure', async () => {
const failure = new Error('pack failed')
const build = async ({ config }) => {
await config.afterSign(context(1))
throw failure
}
await expect(
buildWithOverlappingNotarization(
options(build, async () => {
throw new Error('notary failed too')
})
)
).rejects.toBe(failure)
})
it('preserves hooks and runs artifact hooks after verification', async () => {
const events = []
const config = signedConfig()
config.afterSign = vi.fn()
config.artifactBuildStarted = vi.fn()
await buildWithOverlappingNotarization({ ...options(queuedBuilder(events)), config })
expect(config.afterSign).toHaveBeenCalledTimes(2)
expect(config.artifactBuildStarted).toHaveBeenCalledTimes(2)
expect(config.mac.notarize).toBe(true)
})
it.each([
{ forceCodeSigning: false },
{ mac: { notarize: false, hardenedRuntime: true } },
{ mac: { notarize: true, hardenedRuntime: false } },
{ afterSign: 'hook.cjs' }
])('fails closed on an unsupported signing policy: %j', async (override) => {
const build = vi.fn()
await expect(
buildWithOverlappingNotarization({
...options(build),
config: { ...signedConfig(), ...override }
})
).rejects.toThrow()
expect(build).not.toHaveBeenCalled()
})
it.each([
[context(1), context(1)],
[context(1), { ...context(3), appOutDir: 'dist/mac-1' }],
[{ ...context(1), electronPlatformName: 'linux' }],
[context(4)]
])('rejects duplicate, shared, or unexpected app bundles: %j', async (...contexts) => {
const build = async ({ config }) => {
for (const value of contexts) {
await config.afterSign(value)
}
}
await expect(buildWithOverlappingNotarization(options(build))).rejects.toThrow()
})
it('rejects a build that silently omits an architecture', async () => {
const build = async ({ config }) => {
await config.afterSign(context(1))
return []
}
await expect(buildWithOverlappingNotarization(options(build))).rejects.toThrow('Both macOS')
})
})