native-directory-picker.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. type ExecFileCallback = (
  2. error: (Error & { code?: string | number }) | null,
  3. stdout: string,
  4. stderr: string,
  5. ) => void
  6. type ExecFileMock = (
  7. command: string,
  8. args: readonly string[],
  9. options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
  10. callback: ExecFileCallback,
  11. ) => void
  12. const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
  13. vi.mock('node:child_process', () => ({ execFile: execFileMock }))
  14. import { describe, expect, it, vi } from 'vitest'
  15. import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
  16. function failure(code: string | number, stderr = ''): Error {
  17. return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
  18. }
  19. const signal = () => new AbortController().signal
  20. describe('native directory picker', () => {
  21. it('uses the macOS folder chooser and maps user cancellation to null', async () => {
  22. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
  23. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
  24. expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
  25. run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
  26. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
  27. run.mockRejectedValueOnce(failure(2, 'permission denied'))
  28. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
  29. })
  30. it.each([
  31. ['a primitive error', 'failed'],
  32. ['an invalid code type', { code: true }],
  33. ['a missing stderr property', { code: 1 }],
  34. ['a non-string stderr property', { code: 1, stderr: 42 }],
  35. ])('does not mistake %s for macOS cancellation', async (_label, reason) => {
  36. const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
  37. await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
  38. })
  39. it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
  40. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
  41. await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
  42. expect(run).toHaveBeenCalledWith(
  43. 'powershell.exe',
  44. expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
  45. expect.any(AbortSignal),
  46. )
  47. expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
  48. run.mockResolvedValueOnce({ stdout: '', stderr: '' })
  49. await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
  50. run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
  51. await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
  52. })
  53. it('runs the default command adapter without a shell and preserves command failures', async () => {
  54. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  55. callback(null, 'C:\\work\\default\r\n', '')
  56. })
  57. await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
  58. const [command, args, options] = execFileMock.mock.calls[0]!
  59. expect(command).toBe('powershell.exe')
  60. expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
  61. expect(options.encoding).toBe('utf8')
  62. expect(options.windowsHide).toBe(true)
  63. expect(options.signal).toBeInstanceOf(AbortSignal)
  64. const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
  65. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  66. callback(commandError, 'partial output', 'failure details')
  67. })
  68. await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
  69. message: 'powershell failed', cause: commandError, code: 7,
  70. stdout: 'partial output', stderr: 'failure details',
  71. })
  72. })
  73. it('uses the current process platform when no platform override is supplied', async () => {
  74. const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
  75. await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
  76. })
  77. it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
  78. const run = vi.fn<DirectoryPickerRunner>()
  79. .mockRejectedValueOnce(failure('ENOENT'))
  80. .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
  81. await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
  82. expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
  83. const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
  84. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
  85. .resolves.toBe('/home/test/direct')
  86. expect(zenity).toHaveBeenCalledOnce()
  87. })
  88. it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
  89. const cancelled = vi.fn<DirectoryPickerRunner>(async () => { throw failure(1) })
  90. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull()
  91. const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
  92. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
  93. .rejects.toThrow('install zenity or kdialog')
  94. const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
  95. .mockRejectedValueOnce(failure('ENOENT'))
  96. .mockRejectedValueOnce(failure(1))
  97. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
  98. .resolves.toBeNull()
  99. const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
  100. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
  101. .rejects.toThrow('command failed')
  102. const kdialogFailed = vi.fn<DirectoryPickerRunner>()
  103. .mockRejectedValueOnce(failure('ENOENT'))
  104. .mockRejectedValueOnce(failure(2))
  105. await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
  106. .rejects.toThrow('command failed')
  107. })
  108. it('does not convert caller aborts into user cancellation', async () => {
  109. const abort = new AbortController()
  110. abort.abort(new Error('closed'))
  111. const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
  112. await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
  113. })
  114. it('reports unsupported platforms', async () => {
  115. await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
  116. })
  117. })