From 3f9030a5649a80ffa9e505f796ac1090270eab36 Mon Sep 17 00:00:00 2001 From: jiajia Date: Sat, 23 May 2026 20:24:59 +0800 Subject: [PATCH] Keep project directory browse usable under intercepted pickers Browser directory picking can be intercepted by embedded automation environments, which raises before the user can choose a folder. The project setup browse flow now treats that specific interception as a signal to use the existing server browse-directory fallback, while preserving normal browser picker behavior and cancellation semantics. Constraint: In-app browser automation may install Page.setInterceptFileChooserDialog and break showDirectoryPicker Constraint: Browser File System Access selection should still be preferred when it works Rejected: Always fall back after any browser picker error | permission and unsupported errors should remain visible instead of silently choosing a server path Confidence: high Scope-risk: narrow Directive: Do not broaden fallback errors without checking browser local-project semantics Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "fall back to browse-directory API" Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "Browse Button" Tested: npm run test:run -- src/components/__tests__/ProjectSetupModal.test.tsx --testNamePattern "fall back to browse-directory API|should call browse-directory API|should keep selected parent path|should handle cancelled browse dialog" Tested: npm run build Not-tested: npm run lint | existing Next 16 next lint script resolves to nonexistent /lint directory Not-tested: Full ProjectSetupModal test file | existing Provider tab assertions expect API key inputs hidden by current Popi mode --- src/components/ProjectSetupModal.tsx | 31 +++++++++++-- .../__tests__/ProjectSetupModal.test.tsx | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index e7a91dc6..77b6aabf 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -86,6 +86,20 @@ function getDefaultModelForLLMProvider(provider: LLMProvider): LLMModelType { return LLM_MODELS[provider][0].value; } +function getBrowseErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isDirectoryPickerCancel(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") return true; + const message = getBrowseErrorMessage(error); + return message.includes("User canceled") || message.includes("-128"); +} + +function shouldFallbackToServerDirectoryPicker(error: unknown): boolean { + return getBrowseErrorMessage(error).includes("Page.setInterceptFileChooserDialog"); +} + interface ProjectSetupModalProps { isOpen: boolean; onClose: () => void; @@ -258,11 +272,20 @@ export function ProjectSetupModal({ try { if (isBrowserDirectoryPickerSupported()) { - const browserPath = await pickBrowserWorkflowDirectory(); - if (browserPath) { - setDirectoryPath(browserPath); + try { + const browserPath = await pickBrowserWorkflowDirectory(); + if (browserPath) { + setDirectoryPath(browserPath); + } + return; + } catch (browserPickerError) { + if (isDirectoryPickerCancel(browserPickerError)) { + return; + } + if (!shouldFallbackToServerDirectoryPicker(browserPickerError)) { + throw browserPickerError; + } } - return; } const response = await fetch("/api/browse-directory"); diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index 03614053..6bc65f8d 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -109,6 +109,7 @@ describe("ProjectSetupModal", () => { afterEach(() => { vi.restoreAllMocks(); + delete window.showDirectoryPicker; }); describe("Visibility", () => { @@ -692,6 +693,50 @@ describe("ProjectSetupModal", () => { }); describe("Browse Button", () => { + it("should fall back to browse-directory API when browser directory picker is intercepted", async () => { + Object.defineProperty(window, "showDirectoryPicker", { + value: vi.fn().mockRejectedValue( + new Error("Failed to execute 'showDirectoryPicker' on 'Window': Intercepted by Page.setInterceptFileChooserDialog().") + ), + configurable: true, + }); + + mockFetch.mockImplementation((url: string) => { + if (url === "/api/env-status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }), + }); + } + if (url === "/api/browse-directory") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, path: "/selected/path" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); + }); + + render( + + ); + + fireEvent.click(screen.getByText("Browse")); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory"); + const directoryInput = screen.getByDisplayValue("/selected/path") as HTMLInputElement; + expect(directoryInput.value).toBe("/selected/path"); + }); + + expect(screen.queryByText(/Intercepted by Page\.setInterceptFileChooserDialog/)).not.toBeInTheDocument(); + }); + it("should call browse-directory API when Browse is clicked", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") {