The modern IFileOpenDialog becomes the primary win32 tier: a koffi-driven COM conversation on a worker_threads worker (the modal Show never blocks the host event loop), per-monitor-v2 DPI via SetThreadDpiAwarenessContext, and abort service by re-posting WM_CLOSE to the dialog thread's windows, with terminate+unref as the last resort (Node cannot interrupt a thread blocked in native code, and such a worker must never hold the process open). The PowerShell chain stays as the fallback tier with its trigger widened from ENOENT to any pwsh failure, closing the review-flagged PowerShell 6 regression (no WinForms: exit 1, not ENOENT, so 5.1 never ran). Layering keeps per-file coverage honest on every host: pure sequencing and the driver test against fakes anywhere; the bindings run against a mocked koffi COM world (the session-persistence-jsonl technique); POSIX hosts drive the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The smoke joins processBoundTests: a worker blocked in a native modal wedges the threads pool's teardown, while a fork contains it. The worker bundles as its own CJS tsdown entry (workflow-workerthread's pattern; no TLA), and the host module is imported statically so the node-half bundle stays chunk-free. Built-plane and real-COM behavior verified on native Windows: standalone probes for the source worker, the built CJS worker, and the driver's abort path all open and close the real dialog. Agent Notes: new implemented/feature/2026-08-02-win32-in-process-folder-dialog (bilingual) owns the decision; the DPI note is re-scoped to the fallback tier it now describes and its AutoUpgradeEnabled attribution corrected (.NET Core 3.0 rewrote FolderBrowserDialog; the opt-out arrived in .NET 6).
91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
/**
|
|
* The COM conversation's sequencing against fake bindings: outcome mapping
|
|
* (selection / cancellation / HRESULT failures at every step) and the
|
|
* release-on-every-path guarantee, all platform-independent.
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED,
|
|
runFolderDialog, type Win32DialogBindings, type Win32FolderDialog,
|
|
} from '../src/win32-dialog-logic.ts'
|
|
|
|
const E_FAIL = 0x80004005 | 0
|
|
|
|
interface FakeWorld {
|
|
bindings: Win32DialogBindings
|
|
dpi: ReturnType<typeof vi.fn>
|
|
createDialog: ReturnType<typeof vi.fn>
|
|
dialog: {
|
|
setOptions: ReturnType<typeof vi.fn>
|
|
setTitle: ReturnType<typeof vi.fn>
|
|
show: ReturnType<typeof vi.fn>
|
|
resultPath: ReturnType<typeof vi.fn>
|
|
release: ReturnType<typeof vi.fn>
|
|
}
|
|
}
|
|
|
|
function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorld {
|
|
const dialog = {
|
|
setOptions: vi.fn(() => 0),
|
|
setTitle: vi.fn(() => 0),
|
|
show: vi.fn(() => 0),
|
|
resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })),
|
|
release: vi.fn(),
|
|
...overrides,
|
|
}
|
|
const dpi = vi.fn()
|
|
const createDialog = vi.fn(() => dialog)
|
|
const bindings: Win32DialogBindings = {
|
|
setThreadDpiAwareness: dpi,
|
|
coInitializeSta: vi.fn(() => coInit),
|
|
createFolderDialog: createDialog,
|
|
currentThreadId: vi.fn(() => 4242),
|
|
}
|
|
return { bindings, dpi, createDialog, dialog: dialog as FakeWorld['dialog'] }
|
|
}
|
|
|
|
describe('runFolderDialog', () => {
|
|
it('sequences DPI, STA, options, title, show, and result extraction', () => {
|
|
const { bindings, dpi, dialog } = world()
|
|
const showing = vi.fn()
|
|
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
|
|
expect(dpi).toHaveBeenCalledOnce()
|
|
expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR)
|
|
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
|
|
expect(showing).toHaveBeenCalledWith(4242)
|
|
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
|
|
expect(dialog.release).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('maps the cancelled HRESULT to null and still releases the dialog', () => {
|
|
const { bindings, dialog } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
|
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
|
expect(dialog.resultPath).not.toHaveBeenCalled()
|
|
expect(dialog.release).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => {
|
|
const { bindings } = world({}, 1)
|
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录')
|
|
})
|
|
|
|
it('throws on a failing CoInitializeEx without creating a dialog', () => {
|
|
const { bindings, createDialog } = world({}, E_FAIL)
|
|
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005')
|
|
expect(createDialog).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
['SetOptions', { setOptions: vi.fn(() => E_FAIL) }],
|
|
['SetTitle', { setTitle: vi.fn(() => E_FAIL) }],
|
|
['Show', { show: vi.fn(() => E_FAIL) }],
|
|
['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }],
|
|
] satisfies [string, Partial<Win32FolderDialog>][])('releases the dialog when %s fails', (what, overrides) => {
|
|
const { bindings, dialog } = world(overrides)
|
|
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`)
|
|
expect(dialog.release).toHaveBeenCalledOnce()
|
|
void bindings
|
|
})
|
|
})
|