native-picker.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /**
  2. * Native picker tier selection and the execFile adapter: the Win32 dialog
  3. * primary (failures surface as-is, no fallback tier), the abort rule, and
  4. * the POSIX command tiers (osascript, Zenity → KDialog).
  5. */
  6. type ExecFileCallback = (
  7. error: (Error & { code?: string | number }) | null,
  8. stdout: string,
  9. stderr: string,
  10. ) => void
  11. type ExecFileMock = (
  12. command: string,
  13. args: readonly string[],
  14. options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
  15. callback: ExecFileCallback,
  16. ) => void
  17. const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
  18. vi.mock('node:child_process', () => ({ execFile: execFileMock }))
  19. import { describe, expect, it, vi } from 'vitest'
  20. import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-picker.ts'
  21. function failure(code: string | number, stderr = ''): Error {
  22. return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
  23. }
  24. const signal = () => new AbortController().signal
  25. /** A Win32 dialog that always fails — the no-fallback case. */
  26. const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
  27. describe('native directory picker', () => {
  28. it('uses the macOS folder chooser and maps user cancellation to null', async () => {
  29. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
  30. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
  31. expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
  32. run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
  33. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
  34. run.mockRejectedValueOnce(failure(2, 'permission denied'))
  35. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
  36. })
  37. it.each([
  38. ['a primitive error', 'failed'],
  39. ['an invalid code type', { code: true }],
  40. ['a missing stderr property', { code: 1 }],
  41. ['a non-string stderr property', { code: 1, stderr: 42 }],
  42. ])('does not mistake %s for macOS cancellation', async (_label, reason) => {
  43. const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
  44. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
  45. })
  46. it('uses the Win32 dialog and never spawns a command when it answers', async () => {
  47. const run = vi.fn<DirectoryPickerRunner>()
  48. const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
  49. await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
  50. pickWin32Dialog.mockResolvedValueOnce(null)
  51. await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull()
  52. expect(run).not.toHaveBeenCalled()
  53. })
  54. it('surfaces the Win32 dialog failure with no fallback', async () => {
  55. const run = vi.fn<DirectoryPickerRunner>()
  56. await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
  57. .rejects.toThrow('dialog unavailable')
  58. expect(run).not.toHaveBeenCalled()
  59. })
  60. it('wires the real Win32 dialog as the default tier', async () => {
  61. // A pre-aborted signal makes the DEFAULT dialog deterministic on every
  62. // host: pickWin32Directory throws before spawning any worker or window.
  63. const abort = new AbortController()
  64. abort.abort()
  65. const run = vi.fn<DirectoryPickerRunner>()
  66. await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run }))
  67. .rejects.toThrow('native directory picker aborted')
  68. expect(run).not.toHaveBeenCalled()
  69. })
  70. it('does not fall back when the caller aborted the dialog', async () => {
  71. const abort = new AbortController()
  72. abort.abort(new Error('closed'))
  73. const run = vi.fn<DirectoryPickerRunner>()
  74. await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
  75. expect(run).not.toHaveBeenCalled()
  76. })
  77. it('runs the default command adapter without a shell and preserves command failures', async () => {
  78. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  79. callback(null, '/home/test/project\n', '')
  80. })
  81. await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
  82. const [command, args, options] = execFileMock.mock.calls[0]!
  83. expect(command).toBe('zenity')
  84. expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
  85. expect(options.encoding).toBe('utf8')
  86. expect(options.windowsHide).toBe(true)
  87. expect(options.signal).toBeInstanceOf(AbortSignal)
  88. // A non-cancellation command failure surfaces as-is with its cause and
  89. // captured stdio attached; no tier masks or rewraps it.
  90. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  91. callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
  92. })
  93. const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
  94. .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error)
  95. expect(surfaced).toMatchObject({
  96. message: 'zenity failed', code: 7,
  97. stdout: 'partial output', stderr: 'failure details',
  98. })
  99. expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
  100. })
  101. it('uses the current process platform when no platform override is supplied', async () => {
  102. // Deterministic on every host: the win32 tier answers from the dialog,
  103. // the POSIX tiers from the command runner.
  104. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
  105. const pickWin32Dialog = async (): Promise<string | null> => 'C:\\default\\platform'
  106. const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform'
  107. await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected)
  108. })
  109. it('maps empty command output to cancellation', async () => {
  110. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' }))
  111. await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull()
  112. })
  113. it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
  114. const run = vi.fn<DirectoryPickerRunner>()
  115. .mockRejectedValueOnce(failure('ENOENT'))
  116. .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
  117. await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
  118. expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
  119. const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
  120. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
  121. .resolves.toBe('/home/test/direct')
  122. expect(zenity).toHaveBeenCalledOnce()
  123. })
  124. it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
  125. const cancelled = vi.fn<DirectoryPickerRunner>(async () => { throw failure(1) })
  126. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull()
  127. const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
  128. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
  129. .rejects.toThrow('install zenity or kdialog')
  130. const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
  131. .mockRejectedValueOnce(failure('ENOENT'))
  132. .mockRejectedValueOnce(failure(1))
  133. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
  134. .resolves.toBeNull()
  135. const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
  136. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
  137. .rejects.toThrow('command failed')
  138. const kdialogFailed = vi.fn<DirectoryPickerRunner>()
  139. .mockRejectedValueOnce(failure('ENOENT'))
  140. .mockRejectedValueOnce(failure(2))
  141. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
  142. .rejects.toThrow('command failed')
  143. })
  144. it('does not convert caller aborts into user cancellation', async () => {
  145. const abort = new AbortController()
  146. abort.abort(new Error('closed'))
  147. const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
  148. await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
  149. })
  150. it('reports unsupported platforms', async () => {
  151. await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
  152. })
  153. })