Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3921,6 +3921,94 @@ describe("PreviewManager", () => {
),
);

effectIt.effect(
"hands keyboard focus back to the previous renderer after an automation click",
() =>
withManager((manager) =>
Effect.gen(function* () {
let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "Runtime.evaluate") {
return { result: { value: { width: 800, height: 600 } } };
}
if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
}
return undefined;
});
const restoreFocus = vi.fn();
getFocusedWebContents.mockReturnValue({
id: 7,
isDestroyed: () => false,
focus: restoreFocus,
} as never);
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
isDevToolsOpened: () => false,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
ipc: {
on: vi.fn((channel: string, listener: typeof humanInput) => {
if (channel === "preview:human-input") humanInput = listener;
}),
off: vi.fn(),
},
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setIgnoreMenuShortcuts: vi.fn(),
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
} as never);

yield* manager.createTab("tab_1");
yield* manager.registerWebview("tab_1", 42);
const click = yield* manager
.automationClick("tab_1", { x: 120, y: 80 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
yield* Fiber.join(click);

expect(restoreFocus).toHaveBeenCalledTimes(1);
expect(restoreFocus.mock.invocationCallOrder[0]).toBeGreaterThan(
sendCommand.mock.invocationCallOrder.at(-1) ?? 0,
);

const offscreen = yield* manager
.automationClick("tab_1", { x: 5000, y: 80 })
.pipe(Effect.exit, Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
expect((yield* Fiber.join(offscreen))._tag).toBe("Failure");
expect(restoreFocus).toHaveBeenCalledTimes(2);

// Focus that moved to a third renderer while the click ran is left alone.
getFocusedWebContents
.mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never)
.mockReturnValue({ id: 9, isDestroyed: () => false, focus: vi.fn() } as never);
const moved = yield* manager
.automationClick("tab_1", { x: 120, y: 80 })
.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(200);
yield* Fiber.join(moved);
expect(restoreFocus).toHaveBeenCalledTimes(2);
}),
),
);

effectIt.effect("types in background webviews and enables native key input", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
66 changes: 55 additions & 11 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3719,7 +3719,55 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

// Dispatching input moves keyboard focus into the guest renderer as a side
// effect. Hand it back to whatever had it before, so the user's next keystroke
// does not land in the previewed page, which may not even be visible.
const restoreFocusedWebContents = Effect.fn("PreviewManager.restoreFocusedWebContents")(
function* (
operation: string,
tabId: string,
wc: Electron.WebContents,
previouslyFocused: Electron.WebContents | null,
) {
if (!previouslyFocused || previouslyFocused.id === wc.id || previouslyFocused.isDestroyed()) {
return;
}
// A newer selection the user made while the action ran wins over the restore.
const focusedNow = yield* attempt({ operation, tabId, webContentsId: wc.id }, () =>
webContents.getFocusedWebContents(),
).pipe(Effect.orElseSucceed(() => null));
if (focusedNow && focusedNow.id !== wc.id && focusedNow.id !== previouslyFocused.id) {
return;
}
yield* attempt({ operation, tabId, webContentsId: previouslyFocused.id }, () =>
previouslyFocused.focus(),
).pipe(Effect.ignore);
},
);

const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* (
tabId: string,
wc: Electron.WebContents,
input: PreviewAutomationClickInput,
send: SendCommand,
) {
const previouslyFocused = yield* attempt(
{ operation: "automationClick.getFocusedWebContents", tabId, webContentsId: wc.id },
() => webContents.getFocusedWebContents(),
);
yield* dispatchAutomationClick(tabId, input, send).pipe(
Effect.ensuring(
restoreFocusedWebContents(
"automationClick.restoreFocusedWebContents",
tabId,
wc,
previouslyFocused,
),
),
);
});

const dispatchAutomationClick = Effect.fn("PreviewManager.dispatchAutomationClick")(function* (
tabId: string,
input: PreviewAutomationClickInput,
send: SendCommand,
Expand Down Expand Up @@ -3782,7 +3830,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
const wc = yield* requireWebContents(tabId);
yield* withControlSession(tabId, wc, "click", (send) =>
performAutomationClick(tabId, input, send),
performAutomationClick(tabId, wc, input, send),
);
});

Expand Down Expand Up @@ -3935,16 +3983,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
yield* sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(
Effect.ignore,
);
if (previouslyFocused && previouslyFocused.id !== wc.id && !previouslyFocused.isDestroyed()) {
yield* attempt(
{
operation: "automationPress.restoreFocusedWebContents",
tabId,
webContentsId: previouslyFocused.id,
},
() => previouslyFocused.focus(),
).pipe(Effect.ignore);
}
yield* restoreFocusedWebContents(
"automationPress.restoreFocusedWebContents",
tabId,
wc,
previouslyFocused,
);
});

// Focus the guest WebContents itself, not its containing BrowserWindow. This
Expand Down
25 changes: 15 additions & 10 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
import { isElectron } from "~/env";
import { withPreviewAutomationFocus } from "~/lib/previewAutomationFocus";
import { useEnvironments } from "~/state/environments";
import { previewEnvironment } from "~/state/preview";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
Expand Down Expand Up @@ -650,11 +651,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
return await ready.bridge.automation.snapshot(ready.runtimeTabId);
}
case "click": {
const ready = await requireReadyTab();
return await ready.bridge.automation.click(
ready.runtimeTabId,
request.input as Parameters<typeof ready.bridge.automation.click>[1],
);
return await withPreviewAutomationFocus(async () => {
const ready = await requireReadyTab();
return await ready.bridge.automation.click(
ready.runtimeTabId,
request.input as Parameters<typeof ready.bridge.automation.click>[1],
);
});
}
case "type": {
const ready = await requireReadyTab();
Expand All @@ -664,11 +667,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
);
}
case "press": {
const ready = await requireReadyTab();
return await ready.bridge.automation.press(
ready.runtimeTabId,
request.input as Parameters<typeof ready.bridge.automation.press>[1],
);
return await withPreviewAutomationFocus(async () => {
const ready = await requireReadyTab();
return await ready.bridge.automation.press(
ready.runtimeTabId,
request.input as Parameters<typeof ready.bridge.automation.press>[1],
);
});
}
case "scroll": {
const ready = await requireReadyTab();
Expand Down
Loading
Loading