Skip to content
267 changes: 263 additions & 4 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
})
) {
Expand Down Expand Up @@ -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);
Expand All @@ -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<string | null>();
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<boolean>();
const navigationProbeStarted = Promise.withResolvers<void>();
const navigationProbeFinished = Promise.withResolvers<void>();
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<void>();
const probeSettled = Promise.withResolvers<void>();
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<boolean>();
const probeStarted = Promise.withResolvers<void>();
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<PreviewAutomationStatus>({
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<string | null>();
const acceptRead = Promise.withResolvers<void>();
const current = Promise.withResolvers<boolean>();
const probeStarted = Promise.withResolvers<void>();
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<PreviewAutomationStatus>({
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* () {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
// Same-document navigations (SPA routes) fire no load event.
if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab);
if (!desktop) void recordFrameCopies(tab, frame);
Expand Down Expand Up @@ -1089,13 +1100,25 @@ 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 =
(await chooser
.element()
.getAttribute("accept")
.catch(() => null)) ?? "";
if (!(await isFileChooserCurrent(chooser))) return;
if (previous) closeFileChooser(tab);
tab.fileChooser = {
id: NodeCrypto.randomUUID(),
Expand Down
Loading