mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
230 lines
7.6 KiB
JavaScript
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')
|
|
})
|
|
})
|