refactor(picker): drop the Windows PowerShell fallback chain
The win32 tier is exactly the koffi IFileOpenDialog child process; any failure surfaces as-is. The pwsh -> Windows PowerShell 5.1 cascade, the shared WinForms script, and the triple-miss AggregateError are deleted: koffi is a packaged dependency whose availability the install guarantees, so no mechanism fallback exists (the browse backend remains the fallback at the composition level). The pwsh-first DPI picker-fix note is consolidated into a new simplification note recording the reversal.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
|
||||
README.md: d48622dead56cce842e0bef0207079ff85b22588
|
||||
README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31
|
||||
README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9
|
||||
README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
@@ -17,5 +17,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
|
||||
- **The Windows fallback chain degrades the dialog** — the in-process picker is the modern Explorer-style dialog; where koffi cannot drive COM the PowerShell tiers take over, and a machine that only reaches Windows PowerShell 5.1 gets the legacy folder tree, DPI-corrected but not the modern UI.
|
||||
- **A wedged abort can leak one dialog thread** — when `WM_CLOSE` never lands (the dialog window was never created), the driver terminates and unrefs the worker; Node cannot interrupt a thread blocked in the native modal call, so that thread lives until process exit.
|
||||
- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
@@ -17,5 +17,4 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
|
||||
- **Windows 回退链会降级对话框**——进程内选择器就是现代资源管理器风格对话框;koffi 无法驱动 COM 时由 PowerShell 层级接手,最终只到达 Windows PowerShell 5.1 的机器得到旧版文件夹树,DPI 已修正,但界面不是现代的。
|
||||
- **卡死的中止可能泄漏一个对话框线程**——当 `WM_CLOSE` 始终投递不到(对话框窗口从未创建)时,driver 会 terminate 并 unref 该 worker;Node 无法打断阻塞在原生模态调用里的线程,因此该线程会存活到进程退出。
|
||||
- **Windows 没有机制级回退**——子进程选择器是唯一层级:koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
||||
* with the `native` capability, opening one native OS chooser on the host
|
||||
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
|
||||
* Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM
|
||||
* conversation on a worker thread — and falls back to a PowerShell-hosted
|
||||
* dialog (`pwsh`, then Windows PowerShell 5.1) when that native surface is
|
||||
* unavailable). Only viable when the operator sits at the host's screen;
|
||||
* remote deployments compose the browse backend instead.
|
||||
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
|
||||
* koffi-driven COM conversation on the child's main thread). Only viable when
|
||||
* the operator sits at the host's screen; remote deployments compose the
|
||||
* browse backend instead.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-native
|
||||
*/
|
||||
|
||||
|
||||
@@ -67,62 +67,13 @@ export async function pickNativeDirectory(
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Primary: the in-process koffi-backed IFileOpenDialog worker — the modern
|
||||
// picker with per-monitor-v2 DPI, no PowerShell dependency, and abort
|
||||
// support. Any non-abort failure (koffi unavailable, ancient Windows, COM
|
||||
// refusal) falls back to the PowerShell chain below.
|
||||
// The koffi-backed IFileOpenDialog child process — the modern picker with
|
||||
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
|
||||
// whose availability the install guarantees, so there is no fallback
|
||||
// tier: any failure surfaces as-is (the former PowerShell chain was
|
||||
// removed — see the simplification Agent Note).
|
||||
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
|
||||
let dialogError: unknown
|
||||
try {
|
||||
return await pickDialog(signal)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
dialogError = error
|
||||
}
|
||||
|
||||
// PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder
|
||||
// picker, while Windows PowerShell 5.1's FolderBrowserDialog is hardwired
|
||||
// to the legacy SHBrowseForFolder tree. Prefer pwsh, but ANY pwsh failure
|
||||
// falls back to 5.1 (which every Windows ships): a resolvable pwsh can
|
||||
// still be unable to deliver the dialog — PowerShell 6 has no WinForms,
|
||||
// so its Add-Type exits 1, not ENOENT. Both hosts spawn DPI-unaware, so
|
||||
// the script opts the process into system DPI awareness before any window
|
||||
// is created. No Description is set: the modern dialog renders it as a
|
||||
// bottom strip and the classic dialog as an unthemed box.
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'",
|
||||
'[DpiAware]::SetProcessDPIAware() | Out-Null',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
'$result = $dialog.ShowDialog()',
|
||||
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
||||
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
' [Console]::WriteLine($dialog.SelectedPath)',
|
||||
'}',
|
||||
].join('; ')
|
||||
let pwshError: unknown
|
||||
try {
|
||||
const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
pwshError = error
|
||||
}
|
||||
try {
|
||||
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
// Triple miss: every tier failed. Surface all three causes — the
|
||||
// in-process dialog's reason is otherwise unrecoverable from the last
|
||||
// PowerShell error alone.
|
||||
throw new AggregateError(
|
||||
[dialogError, pwshError, error],
|
||||
'native directory picker failed: the in-process dialog and both PowerShell hosts failed',
|
||||
)
|
||||
}
|
||||
return await pickDialog(signal)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Native picker tier selection and the execFile adapter: the in-process
|
||||
* dialog primary, the pwsh → Windows PowerShell 5.1 fallback chain (any
|
||||
* non-abort pwsh failure cascades), the abort-never-falls-through rule, and
|
||||
* the triple-miss AggregateError carrying the dialog/pwsh/5.1 causes.
|
||||
* Native picker tier selection and the execFile adapter: the Win32 dialog
|
||||
* primary (failures surface as-is, no fallback tier), the abort rule, and
|
||||
* the POSIX command tiers (osascript, Zenity → KDialog).
|
||||
*/
|
||||
|
||||
type ExecFileCallback = (
|
||||
@@ -30,7 +29,7 @@ function failure(code: string | number, stderr = ''): Error {
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
/** The PowerShell chain is reachable only when the in-process dialog fails. */
|
||||
/** A Win32 dialog that always fails — the no-fallback case. */
|
||||
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
|
||||
|
||||
describe('native directory picker', () => {
|
||||
@@ -56,7 +55,7 @@ describe('native directory picker', () => {
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('prefers the in-process Win32 dialog and never spawns PowerShell when it answers', async () => {
|
||||
it('uses the Win32 dialog and never spawns a command when it answers', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
|
||||
@@ -65,55 +64,11 @@ describe('native directory picker', () => {
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to pwsh when the dialog is unavailable and maps empty output to cancellation', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\project')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'pwsh.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const script = run.mock.calls[0]?.[1].at(-1)
|
||||
expect(script).toContain("$ErrorActionPreference = 'Stop'")
|
||||
expect(script).toContain('SetProcessDPIAware')
|
||||
// Description renders as a bottom strip (modern) / unthemed box (classic); never set it.
|
||||
expect(script).not.toContain('Description')
|
||||
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to Windows PowerShell 5.1 whenever pwsh cannot deliver the dialog', async () => {
|
||||
it('surfaces the Win32 dialog failure with no fallback', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\fallback')
|
||||
expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
|
||||
// Both runtimes execute the identical script, so DPI awareness holds either way.
|
||||
expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1))
|
||||
|
||||
// A resolvable pwsh that cannot deliver the dialog (PowerShell 6: no
|
||||
// WinForms, Add-Type exits 1 - not ENOENT) reaches 5.1 all the same.
|
||||
const pwsh6 = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure(1, "Cannot load assembly 'System.Windows.Forms'"))
|
||||
.mockResolvedValueOnce({ stdout: 'C:\\work\\legacy\r\n', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: pwsh6, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\legacy')
|
||||
expect(pwsh6.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
|
||||
|
||||
const cancelled = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull()
|
||||
|
||||
// Triple miss: the surfaced AggregateError carries all three causes,
|
||||
// including the otherwise-lost in-process dialog failure.
|
||||
const failed = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
.mockRejectedValueOnce(failure(2))
|
||||
const tripleMiss = await pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog })
|
||||
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError)
|
||||
expect(tripleMiss.message).toContain('the in-process dialog and both PowerShell hosts failed')
|
||||
expect((tripleMiss.errors[0] as Error).message).toBe('dialog unavailable')
|
||||
expect((tripleMiss.errors[2] as Error).message).toContain('command failed')
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
|
||||
.rejects.toThrow('dialog unavailable')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wires the real Win32 dialog as the default tier', async () => {
|
||||
@@ -127,52 +82,38 @@ describe('native directory picker', () => {
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fall back when the caller aborted the dialog or the pwsh spawn', async () => {
|
||||
it('does not fall back when the caller aborted the dialog', async () => {
|
||||
const abort = new AbortController()
|
||||
abort.abort(new Error('closed'))
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
const liveThenAborted = new AbortController()
|
||||
const abortingRun = vi.fn<DirectoryPickerRunner>(async () => {
|
||||
liveThenAborted.abort(new Error('closed'))
|
||||
throw failure('ENOENT')
|
||||
})
|
||||
await expect(pickNativeDirectory(liveThenAborted.signal, { platform: 'win32', run: abortingRun, pickWin32Dialog: noDialog }))
|
||||
.rejects.toThrow('command failed')
|
||||
expect(abortingRun).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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', '')
|
||||
callback(null, '/home/test/project\n', '')
|
||||
})
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\default')
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
|
||||
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||
expect(command).toBe('pwsh.exe')
|
||||
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
|
||||
expect(command).toBe('zenity')
|
||||
expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
|
||||
expect(options.encoding).toBe('utf8')
|
||||
expect(options.windowsHide).toBe(true)
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
// Both chain tiers fail: pwsh's code-7 failure now reaches 5.1, whose
|
||||
// failure is the one the caller sees.
|
||||
const pwshError = Object.assign(new Error('pwsh failed'), { code: 7 })
|
||||
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
|
||||
// A non-cancellation command failure surfaces as-is with its cause and
|
||||
// captured stdio attached; no tier masks or rewraps it.
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(pwshError, '', 'no WinForms')
|
||||
callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
|
||||
})
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
callback(commandError, 'partial output', 'failure details')
|
||||
})
|
||||
const surfaced = await pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })
|
||||
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError)
|
||||
expect(surfaced.errors[2]).toMatchObject({
|
||||
message: 'powershell failed', cause: commandError, code: 7,
|
||||
const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
|
||||
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error)
|
||||
expect(surfaced).toMatchObject({
|
||||
message: 'zenity failed', code: 7,
|
||||
stdout: 'partial output', stderr: 'failure details',
|
||||
})
|
||||
expect(execFileMock.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'pwsh.exe', 'powershell.exe'])
|
||||
expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('uses the current process platform when no platform override is supplied', async () => {
|
||||
@@ -184,6 +125,11 @@ describe('native directory picker', () => {
|
||||
await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected)
|
||||
})
|
||||
|
||||
it('maps empty command output to cancellation', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
||||
const run = vi.fn<DirectoryPickerRunner>()
|
||||
.mockRejectedValueOnce(failure('ENOENT'))
|
||||
|
||||
Reference in New Issue
Block a user