package-macos.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /** Exercise notarization overlap and artifact isolation without Apple credentials or network. */
  2. import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  3. import { existsSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { describe, expect, it, vi } from 'vitest'
  7. import {
  8. packageMacOSArtifacts,
  9. type DesktopPrepackagedArtifact,
  10. type MacOSArtifactOperations,
  11. } from '../scripts/package-macos.ts'
  12. import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts'
  13. const environment = {
  14. DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
  15. DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
  16. APPLE_KEYCHAIN_PROFILE: 'fixture-profile',
  17. }
  18. function barrier() {
  19. let release!: () => void
  20. const promise = new Promise<void>((resolve) => { release = resolve })
  21. return { promise, release }
  22. }
  23. async function fixture(arch: 'arm64' | 'x64' = 'arm64') {
  24. const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-'))
  25. const artifactsRoot = join(root, 'artifacts')
  26. const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
  27. await mkdir(appPath, { recursive: true })
  28. await writeFile(join(appPath, 'payload'), 'signed content')
  29. const version = '1.2.3-alpha.1'
  30. const base = `deepseek-harness-${version}-mac-${arch}`
  31. const request = { arch, artifactsRoot, version, environment }
  32. const apple: MacOSArtifactOperations = {
  33. copyApp: async (source, destination) => {
  34. await cp(source, destination, { recursive: true, verbatimSymlinks: true })
  35. },
  36. notarize: async ({ appPath: path }) => { await writeFile(join(path, 'ticket'), 'accepted') },
  37. verifySignature: vi.fn(),
  38. verifyNotarization: vi.fn((path: string) => {
  39. if (!existsSync(join(path, 'ticket'))) throw new Error('missing App ticket')
  40. }),
  41. }
  42. const build = async (artifact: DesktopPrepackagedArtifact) => {
  43. await mkdir(artifact.output, { recursive: true })
  44. const contents = JSON.stringify({
  45. payload: await readFile(join(artifact.appPath, 'payload'), 'utf8'),
  46. appTicket: existsSync(join(artifact.appPath, 'ticket')),
  47. })
  48. await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents)
  49. if (artifact.format === 'zip') {
  50. await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap')
  51. await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata')
  52. }
  53. }
  54. return { root, appPath, request, apple, build, base }
  55. }
  56. describe('parallel macOS artifacts', () => {
  57. it.each(['arm64', 'x64'] as const)('overlaps notarization on isolated %s copies and promotes only completed payloads', async (arch) => {
  58. const f = await fixture(arch)
  59. const appStarted = barrier()
  60. const appAccepted = barrier()
  61. const dmgCompleted = barrier()
  62. const zipCompleted = barrier()
  63. const starts: string[] = []
  64. const copies: string[] = []
  65. const operation = packageMacOSArtifacts(f.request, async (artifact) => {
  66. starts.push(artifact.format)
  67. if (artifact.format === 'dmg') await dmgCompleted.promise
  68. await f.build(artifact)
  69. if (artifact.format === 'zip') zipCompleted.release()
  70. }, {
  71. ...f.apple,
  72. copyApp: async (source, destination) => {
  73. copies.push(destination)
  74. await f.apple.copyApp(source, destination)
  75. },
  76. notarize: async (options) => {
  77. starts.push('app')
  78. appStarted.release()
  79. await appAccepted.promise
  80. await f.apple.notarize(options)
  81. },
  82. })
  83. try {
  84. await appStarted.promise
  85. await vi.waitFor(() => { expect([...starts]).toEqual(expect.arrayContaining(['app', 'dmg'])) })
  86. expect(new Set(copies).size).toBe(2)
  87. expect(copies.every(path => path !== f.appPath)).toBe(true)
  88. appAccepted.release()
  89. await zipCompleted.promise
  90. expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
  91. expect(existsSync(join(f.request.artifactsRoot, `${f.base}.zip`))).toBe(false)
  92. dmgCompleted.release()
  93. await operation
  94. expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.zip`), 'utf8')))
  95. .toEqual({ payload: 'signed content', appTicket: true })
  96. expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8')))
  97. .toEqual({ payload: 'signed content', appTicket: false })
  98. expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted')
  99. expect((await readdir(f.root)).sort()).toEqual(['artifacts'])
  100. expect(f.apple.verifySignature).toHaveBeenCalledTimes(2)
  101. expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1)
  102. } finally {
  103. appAccepted.release()
  104. dmgCompleted.release()
  105. await Promise.allSettled([operation])
  106. await rm(f.root, { recursive: true, force: true })
  107. }
  108. })
  109. it('collects both failures after both lanes release their copies and publishes neither payload', async () => {
  110. const f = await fixture()
  111. const appStarted = barrier()
  112. const failApp = barrier()
  113. const failDmg = barrier()
  114. const appError = new Error('App rejected')
  115. const dmgError = new Error('DMG rejected')
  116. const released: string[] = []
  117. const outcome = packageMacOSArtifacts(f.request, async (artifact) => {
  118. expect(artifact.format).toBe('dmg')
  119. await failDmg.promise
  120. expect(await readFile(join(artifact.appPath, 'payload'), 'utf8')).toBe('signed content')
  121. released.push('dmg')
  122. throw dmgError
  123. }, {
  124. ...f.apple,
  125. notarize: async () => {
  126. appStarted.release()
  127. await failApp.promise
  128. released.push('app')
  129. throw appError
  130. },
  131. }).catch((error: unknown) => error)
  132. try {
  133. await appStarted.promise
  134. failApp.release()
  135. failDmg.release()
  136. const error = await outcome
  137. expect(error).toBeInstanceOf(AggregateError)
  138. expect((error as AggregateError).errors).toEqual([appError, dmgError])
  139. expect(released.sort()).toEqual(['app', 'dmg'])
  140. expect(await readdir(f.root)).toEqual(['artifacts'])
  141. expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
  142. expect(existsSync(join(f.appPath, 'ticket'))).toBe(false)
  143. } finally {
  144. failApp.release()
  145. failDmg.release()
  146. await outcome
  147. await rm(f.root, { recursive: true, force: true })
  148. }
  149. })
  150. it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => {
  151. const f = await fixture()
  152. try {
  153. const apple: MacOSArtifactOperations = {
  154. ...f.apple,
  155. ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}),
  156. ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}),
  157. ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}),
  158. }
  159. await expect(packageMacOSArtifacts(f.request, async (artifact) => {
  160. await f.build(artifact)
  161. if (failure === 'metadata' && artifact.format === 'zip') {
  162. await writeFile(join(artifact.output, 'alpha-mac.yml'), '')
  163. }
  164. }, apple)).rejects.toThrow()
  165. expect(await readdir(f.root)).toEqual(['artifacts'])
  166. expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64'])
  167. } finally { await rm(f.root, { recursive: true, force: true }) }
  168. })
  169. it('passes the actual App and isolated output directory to each single-target builder', () => {
  170. const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')
  171. for (const format of ['zip', 'dmg'] as const) {
  172. const appPath = join('private build', format, 'DeepSeek Harness.app')
  173. const output = join(dirname(appPath), 'artifacts')
  174. expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([
  175. 'exec', 'electron-builder', '--config', 'electron-builder.config.mjs',
  176. '--mac', format, '--arm64', '--publish', 'never',
  177. '--config.mac.notarize=false',
  178. '--prepackaged', appPath, '--config.directories.output', output,
  179. ])
  180. }
  181. })
  182. })