diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2f09e2b08461..bf24dd09eb78 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -137,6 +137,18 @@ function makeSession( }; } +function fileChooserElement(accept: string) { + return { + getAttribute: async () => accept, + evaluate: (probe: (element: { ownerDocument: unknown }) => boolean) => + Promise.resolve( + probe({ + ownerDocument: (globalThis as typeof globalThis & { document?: unknown }).document, + }), + ), + }; +} + function makeContext(onClose?: (context: BrowserContext) => void) { const events = new NodeEvents.EventEmitter(); const nativeFileChooser = vi.fn(); @@ -158,7 +170,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { if ( !events.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles: setChooserFiles, }) ) { @@ -1145,7 +1157,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file const setFiles = vi.fn(async (_files: unknown) => {}); page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles, }); let offered = yield* Queue.take(viewer.output); @@ -1170,6 +1182,253 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); +it.live("overlapping file pickers keep the newer offer answerable", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const firstFiles = vi.fn(async (_files: unknown) => {}); + const secondFiles = vi.fn(async (_files: unknown) => {}); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ ...fileChooserElement(""), getAttribute: () => accept.promise }), + setFiles: firstFiles, + }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => fileChooserElement(".csv"), + setFiles: secondFiles, + }); + accept.resolve(".txt"); + let first = yield* Queue.take(viewer.output); + while (first._tag !== "fileChooser") first = yield* Queue.take(viewer.output); + let second = yield* Queue.take(viewer.output); + while (second._tag !== "fileChooser") second = yield* Queue.take(viewer.output); + expect(second.accept).toBe(".csv"); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: second.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + expect(firstFiles).not.toHaveBeenCalled(); + expect(secondFiles).toHaveBeenCalledOnce(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a detached file picker stays open across same-document navigation", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const pageDocument = {}; + vi.stubGlobal("document", pageDocument); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.unstubAllGlobals())); + const detachedInput = { isConnected: false, ownerDocument: pageDocument }; + const navigationProbe = Promise.withResolvers(); + const navigationProbeStarted = Promise.withResolvers(); + const navigationProbeFinished = Promise.withResolvers(); + let probeCount = 0; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: (probe: (element: typeof detachedInput) => boolean) => { + probeCount++; + if (probeCount === 1) return Promise.resolve(probe(detachedInput)); + navigationProbeStarted.resolve(); + return navigationProbe.promise.then((current) => { + const result = probe(detachedInput); + navigationProbeFinished.resolve(); + return current && result; + }); + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("framenavigated", page); + yield* Effect.promise(() => navigationProbeStarted.promise); + navigationProbe.resolve(true); + yield* Effect.promise(() => navigationProbeFinished.promise); + yield* viewer.input({ type: "releaseControl" }); + let closedAfterNavigation = false; + let control = yield* Queue.take(viewer.output); + while (control._tag !== "control" || control.controller === "you") { + if (control._tag === "fileChooserClosed") closedAfterNavigation = true; + control = yield* Queue.take(viewer.output); + } + expect(closedAfterNavigation).toBe(false); + yield* viewer.input({ type: "takeControl" }); + let restoredControl = yield* Queue.take(viewer.output); + while (restoredControl._tag !== "control" || restoredControl.controller !== "you") { + restoredControl = yield* Queue.take(viewer.output); + } + + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live.each(["main", "iframe"])( + "a file picker closes at %s document commit before DOMContentLoaded", + (frame) => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const setFiles = vi.fn(async (_files: unknown) => {}); + const probeStarted = Promise.withResolvers(); + const probeSettled = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return Promise.reject(new Error("Execution context was destroyed")).finally(() => + probeSettled.resolve(), + ); + }, + }), + setFiles, + }); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); + + page.emit("framenavigated", frame === "main" ? page : makeFrame()); + yield* Effect.promise(() => probeStarted.promise); + yield* Effect.promise(() => probeSettled.promise); + let closed = yield* Queue.take(viewer.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output); + expect(closed.id).toBe(offered.id); + expect( + yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.csv", mimeType: "text/csv", buffer: Buffer.from("a") }], + }), + ).toBe(false); + expect(setFiles).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker closes when its page is replaced while control changes hands", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const first = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "takeControl" }); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + let probeCount = 0; + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: async () => "", + evaluate: () => { + probeCount++; + if (probeCount === 1) return Promise.resolve(true); + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + let offered = yield* Queue.take(first.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); + + page.emit("framenavigated", page); + yield* Effect.promise(() => probeStarted.promise); + const second = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "releaseControl" }); + yield* second.input({ type: "takeControl" }); + let reoffered = yield* Queue.take(second.output); + while (reoffered._tag !== "fileChooser") reoffered = yield* Queue.take(second.output); + current.resolve(false); + let closed = yield* Queue.take(second.output); + while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(second.output); + expect(closed.id).toBe(reoffered.id); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file picker is not offered after its document changes while reading accept", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, browser, tabId } = yield* ready; + const page = contexts[0]!.page; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const accept = Promise.withResolvers(); + const acceptRead = Promise.withResolvers(); + const current = Promise.withResolvers(); + const probeStarted = Promise.withResolvers(); + page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ + getAttribute: () => { + acceptRead.resolve(); + return accept.promise; + }, + evaluate: () => { + probeStarted.resolve(); + return current.promise; + }, + }), + setFiles: vi.fn(async (_files: unknown) => {}), + }); + + yield* Effect.promise(() => acceptRead.promise); + page.emit("framenavigated", page); + accept.resolve(""); + yield* Effect.promise(() => accept.promise); + yield* Effect.promise(() => probeStarted.promise); + current.resolve(false); + yield* Effect.promise(() => current.promise); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.fileChooser).toBeNull(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("only the current controller can answer a file picker", () => Effect.scoped( Effect.gen(function* () { @@ -1179,7 +1438,7 @@ it.live("only the current controller can answer a file picker", () => const setFiles = vi.fn(async (_files: unknown) => {}); contexts[0]!.page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => "" }), + element: () => fileChooserElement(""), setFiles, }); let offered = yield* Queue.take(first.output); @@ -1288,7 +1547,7 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", yield* viewer.input({ type: "takeControl" }); context.page.emit("filechooser", { isMultiple: () => false, - element: () => ({ getAttribute: async () => ".csv" }), + element: () => fileChooserElement(".csv"), setFiles: vi.fn(async () => {}), }); let chooser = yield* Queue.take(viewer.output); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 57e35be77863..75674d2b22ae 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -809,6 +809,7 @@ const make = Effect.gen(function* () { const control = new SessionControl(snapshot.automationOwner ?? null, () => ServerBrowserPage.invalidateRefs(page), ); + let pendingFileChooserOffer = Promise.resolve(); const tab: ServerTab = { threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId, @@ -824,7 +825,11 @@ const make = Effect.gen(function* () { control, agentOperating: false, fileChooserIntercepted: false, - onFileChooser: (chooser) => void offerFileChooser(tab, chooser), + onFileChooser: (chooser) => { + pendingFileChooserOffer = pendingFileChooserOffer + .then(() => offerFileChooser(tab, chooser)) + .catch(constVoid); + }, isolatedContext, desktop: desktop === null ? null : { close: desktop.close }, profileId: snapshot.profileId, @@ -886,6 +891,12 @@ const make = Effect.gen(function* () { void reportLoaded(tab); }); page.on("framenavigated", (frame) => { + const open = tab.fileChooser; + if (open) { + void isFileChooserCurrent(open.chooser).then((current) => { + if (!current && tab.fileChooser?.chooser === open.chooser) closeFileChooser(tab); + }); + } // Same-document navigations (SPA routes) fire no load event. if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab); if (!desktop) void recordFrameCopies(tab, frame); @@ -1089,6 +1100,17 @@ const make = Effect.gen(function* () { } : null; + // Not isConnected: upload helpers often click an input never added to the page. The handle's + // context is destroyed with its document, so a replaced document rejects. + const isFileChooserCurrent = (chooser: FileChooser) => + chooser + .element() + .evaluate( + (element) => + element.ownerDocument === (globalThis as unknown as { document: unknown }).document, + ) + .catch(() => false); + const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => { const previous = tab.fileChooser; const accept = @@ -1096,6 +1118,7 @@ const make = Effect.gen(function* () { .element() .getAttribute("accept") .catch(() => null)) ?? ""; + if (!(await isFileChooserCurrent(chooser))) return; if (previous) closeFileChooser(tab); tab.fileChooser = { id: NodeCrypto.randomUUID(),