native-path-opener.spec.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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 { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
  16. const signal = () => new AbortController().signal
  17. describe('native path opener', () => {
  18. it('opens with macOS open(1)', async () => {
  19. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  20. await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run })
  21. expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
  22. })
  23. it('bypasses macOS file associations for text documents', async () => {
  24. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  25. await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
  26. expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
  27. })
  28. it('uses the Linux desktop association for text documents', async () => {
  29. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  30. await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
  31. expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
  32. })
  33. it('opens with Windows Invoke-Item and escapes single quotes', async () => {
  34. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  35. await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
  36. expect(run).toHaveBeenCalledWith(
  37. 'powershell.exe',
  38. ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"],
  39. expect.any(AbortSignal),
  40. )
  41. })
  42. it('uses the Windows desktop association for text documents', async () => {
  43. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  44. await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
  45. expect(run).toHaveBeenCalledWith(
  46. 'powershell.exe',
  47. ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
  48. expect.any(AbortSignal),
  49. )
  50. })
  51. it('opens with Linux xdg-open', async () => {
  52. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  53. await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
  54. expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal))
  55. })
  56. it('rejects unsupported platforms', async () => {
  57. await expect(openNativePath('/x', signal(), { platform: 'freebsd' as NodeJS.Platform }))
  58. .rejects.toThrow('unsupported on freebsd')
  59. })
  60. it('uses the current process platform when no platform override is supplied', async () => {
  61. const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
  62. await openNativePath('/tmp/platform-default.txt', signal(), { run })
  63. const expected = process.platform === 'win32'
  64. ? 'powershell.exe'
  65. : process.platform === 'linux'
  66. ? 'xdg-open'
  67. : 'open'
  68. expect(run.mock.calls[0]?.[0]).toBe(expected)
  69. })
  70. it('runs the default command adapter without a shell and preserves command failures', async () => {
  71. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  72. callback(null, '', '')
  73. })
  74. await openNativePath('/tmp/default.txt', signal(), { platform: 'darwin' })
  75. const [command, args, options] = execFileMock.mock.calls[0]!
  76. expect(command).toBe('open')
  77. expect(args).toEqual(['/tmp/default.txt'])
  78. expect(options.encoding).toBe('utf8')
  79. expect(options.windowsHide).toBe(true)
  80. expect(options.signal).toBeInstanceOf(AbortSignal)
  81. const commandError = Object.assign(new Error('open failed'), { code: 1 })
  82. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
  83. callback(commandError, 'partial output', 'failure details')
  84. })
  85. await expect(openNativePath('/tmp/missing.txt', signal(), { platform: 'darwin' })).rejects.toMatchObject({
  86. message: 'open failed', cause: commandError, code: 1,
  87. stdout: 'partial output', stderr: 'failure details',
  88. })
  89. })
  90. })