test(gui): cover native workspace picker branches
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
|
import type { IncomingHttpHeaders } from 'node:http'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||||
|
|
||||||
function request(
|
function request(
|
||||||
remoteAddress: string,
|
remoteAddress: string | undefined,
|
||||||
headers: Record<string, string> = {
|
headers: IncomingHttpHeaders = {
|
||||||
host: '127.0.0.1:3080',
|
host: '127.0.0.1:3080',
|
||||||
origin: 'http://127.0.0.1:3080',
|
origin: 'http://127.0.0.1:3080',
|
||||||
'sec-fetch-site': 'same-origin',
|
'sec-fetch-site': 'same-origin',
|
||||||
@@ -22,10 +23,14 @@ describe('native dialog request trust', () => {
|
|||||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||||
}))).toBe(true)
|
}))).toBe(true)
|
||||||
|
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||||
|
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||||
|
}))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||||
|
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||||
}))).toBe(false)
|
}))).toBe(false)
|
||||||
@@ -33,8 +38,20 @@ describe('native dialog request trust', () => {
|
|||||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||||
}))).toBe(false)
|
}))).toBe(false)
|
||||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||||
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
|
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||||
|
}))).toBe(false)
|
||||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||||
}))).toBe(false)
|
}))).toBe(false)
|
||||||
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
|
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||||
|
}))).toBe(false)
|
||||||
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
|
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||||
|
}))).toBe(false)
|
||||||
|
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||||
|
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||||
|
}))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export function WorkspaceCreateFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openLocalFolder = (): void => {
|
const openLocalFolder = (): void => {
|
||||||
if (pickingFolder) return
|
|
||||||
onClose()
|
onClose()
|
||||||
setModalKind(null)
|
setModalKind(null)
|
||||||
setModalError(null)
|
setModalError(null)
|
||||||
@@ -109,7 +108,6 @@ export function WorkspaceCreateFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (id: string): void => {
|
const handleSelect = (id: string): void => {
|
||||||
if (pickingFolder) return
|
|
||||||
if (id === OPEN_LOCAL_FOLDER) {
|
if (id === OPEN_LOCAL_FOLDER) {
|
||||||
openLocalFolder()
|
openLocalFolder()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ describe('WorkspacePicker', () => {
|
|||||||
await act(async () => { resolve(null); await pending })
|
await act(async () => { resolve(null); await pending })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports non-Error native picker failures', async () => {
|
||||||
|
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||||
|
chooseItem('Open local folder…')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||||
|
})
|
||||||
|
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('closes a creation modal when the user cancels', () => {
|
it('closes a creation modal when the user cancels', () => {
|
||||||
mount([])
|
mount([])
|
||||||
chooseItem('Create a new workspace')
|
chooseItem('Create a new workspace')
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
type ExecFileCallback = (
|
||||||
|
error: (Error & { code?: string | number }) | null,
|
||||||
|
stdout: string,
|
||||||
|
stderr: string,
|
||||||
|
) => void
|
||||||
|
type ExecFileMock = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
|
||||||
|
callback: ExecFileCallback,
|
||||||
|
) => void
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
|
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
|
||||||
|
|
||||||
@@ -15,6 +31,19 @@ describe('native directory picker', () => {
|
|||||||
|
|
||||||
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
|
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
|
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
|
||||||
|
|
||||||
|
run.mockRejectedValueOnce(failure(2, 'permission denied'))
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a primitive error', 'failed'],
|
||||||
|
['an invalid code type', { code: true }],
|
||||||
|
['a missing stderr property', { code: 1 }],
|
||||||
|
['a non-string stderr property', { code: 1, stderr: 42 }],
|
||||||
|
])('does not mistake %s for macOS cancellation', async (_label, reason) => {
|
||||||
|
const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
|
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
|
||||||
@@ -32,12 +61,44 @@ describe('native directory picker', () => {
|
|||||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
|
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||||
|
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||||
|
callback(null, 'C:\\work\\default\r\n', '')
|
||||||
|
})
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
|
||||||
|
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||||
|
expect(command).toBe('powershell.exe')
|
||||||
|
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
|
||||||
|
expect(options.encoding).toBe('utf8')
|
||||||
|
expect(options.windowsHide).toBe(true)
|
||||||
|
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||||
|
|
||||||
|
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
|
||||||
|
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||||
|
callback(commandError, 'partial output', 'failure details')
|
||||||
|
})
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
|
||||||
|
message: 'powershell failed', cause: commandError, code: 7,
|
||||||
|
stdout: 'partial output', stderr: 'failure details',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the current process platform when no platform override is supplied', async () => {
|
||||||
|
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
|
||||||
|
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
|
||||||
|
})
|
||||||
|
|
||||||
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
||||||
const run = vi.fn<DirectoryPickerRunner>()
|
const run = vi.fn<DirectoryPickerRunner>()
|
||||||
.mockRejectedValueOnce(failure('ENOENT'))
|
.mockRejectedValueOnce(failure('ENOENT'))
|
||||||
.mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
|
.mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
|
||||||
expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
|
expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
|
||||||
|
|
||||||
|
const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
|
||||||
|
.resolves.toBe('/home/test/direct')
|
||||||
|
expect(zenity).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
|
it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
|
||||||
@@ -47,6 +108,22 @@ describe('native directory picker', () => {
|
|||||||
const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
|
const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
|
||||||
.rejects.toThrow('install zenity or kdialog')
|
.rejects.toThrow('install zenity or kdialog')
|
||||||
|
|
||||||
|
const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
|
||||||
|
.mockRejectedValueOnce(failure('ENOENT'))
|
||||||
|
.mockRejectedValueOnce(failure(1))
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
|
||||||
|
.resolves.toBeNull()
|
||||||
|
|
||||||
|
const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
|
||||||
|
.rejects.toThrow('command failed')
|
||||||
|
|
||||||
|
const kdialogFailed = vi.fn<DirectoryPickerRunner>()
|
||||||
|
.mockRejectedValueOnce(failure('ENOENT'))
|
||||||
|
.mockRejectedValueOnce(failure(2))
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
|
||||||
|
.rejects.toThrow('command failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not convert caller aborts into user cancellation', async () => {
|
it('does not convert caller aborts into user cancellation', async () => {
|
||||||
@@ -55,4 +132,8 @@ describe('native directory picker', () => {
|
|||||||
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
|
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
|
||||||
await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
|
await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reports unsupported platforms', async () => {
|
||||||
|
await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user