Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
115 changes: 47 additions & 68 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3926,28 +3926,20 @@ describe("PreviewManager", () => {
Effect.gen(function* () {
let failKeyDown = false;
let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (
failKeyDown &&
method === "Input.dispatchKeyEvent" &&
(params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
) {
throw new Error("key dispatch failed");
}
if (
method === "Input.dispatchKeyEvent" &&
(params?.["type"] === "keyDown" || params?.["type"] === "rawKeyDown")
) {
humanInput?.(
{},
{
kind: "key",
key: params["key"],
code: params["code"] ?? "Digit1",
},
);
}
return method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined;
const sendCommand = vi.fn(async (method: string, _params?: Record<string, unknown>) =>
method === "Runtime.evaluate" ? { result: { value: { ok: true } } } : undefined,
);
const sendInputEvent = vi.fn((input: Electron.KeyboardInputEvent) => {
if (input.type !== "keyDown") return;
if (failKeyDown) throw new Error("key dispatch failed");
humanInput?.(
{},
{
kind: "key",
key: input.keyCode,
code: input.keyCode === "!" ? "Digit1" : `Key${input.keyCode.toUpperCase()}`,
},
);
});
const restoreFocus = vi.fn();
const focus = vi.fn();
Expand All @@ -3965,6 +3957,7 @@ describe("PreviewManager", () => {
isLoading: () => false,
isDevToolsOpened: () => false,
focus,
sendInputEvent,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
Expand Down Expand Up @@ -4003,13 +3996,6 @@ describe("PreviewManager", () => {
([method, params]) =>
method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true,
);
const keyDownIndex = calls.findIndex(
([method, params]) =>
method === "Input.dispatchKeyEvent" && params?.["type"] === "keyDown",
);
const keyUpIndex = calls.findIndex(
([method, params]) => method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
);
const focusOffIndex = calls.findIndex(
([method, params]) =>
method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false,
Expand Down Expand Up @@ -4037,61 +4023,54 @@ describe("PreviewManager", () => {
expect(clearOnlyEvaluation).toBeDefined();
expect(methods).not.toContain("Input.insertText");
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(focus).toHaveBeenCalledOnce();
expect(restoreFocus).toHaveBeenCalledOnce();
expect(methods).toContain("Page.bringToFront");
expect(focus).not.toHaveBeenCalled();
expect(restoreFocus).not.toHaveBeenCalled();
expect(methods).not.toContain("Page.bringToFront");
expect(methods).not.toContain("Input.dispatchKeyEvent");
expect(enableIndex).toBeLessThan(focusOnIndex);
expect(focusOnIndex).toBeLessThan(keyDownIndex);
expect(keyDownIndex).toBeLessThan(keyUpIndex);
expect(keyUpIndex).toBeLessThan(focusOffIndex);
expect(
calls.filter(
([method, params]) =>
method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
),
).toHaveLength(1);
expect(sendCommand.mock.invocationCallOrder[focusOnIndex]).toBeLessThan(
sendInputEvent.mock.invocationCallOrder[0]!,
);
expect(sendInputEvent.mock.invocationCallOrder[2]).toBeLessThan(
sendCommand.mock.invocationCallOrder[focusOffIndex]!,
);
expect(sendInputEvent.mock.calls.map(([input]) => input)).toEqual([
{ type: "keyDown", keyCode: "x", modifiers: [], skipIfUnhandled: true },
{ type: "char", keyCode: "x", modifiers: [], skipIfUnhandled: true },
{ type: "keyUp", keyCode: "x", modifiers: [], skipIfUnhandled: true },
]);
expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false });

sendCommand.mockClear();
sendInputEvent.mockClear();
failKeyDown = true;
const failedPress = yield* Effect.exit(manager.automationPress("tab_input", { key: "y" }));

expect(Exit.isFailure(failedPress)).toBe(true);
expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", {
type: "keyUp",
key: "y",
code: "KeyY",
modifiers: 0,
windowsVirtualKeyCode: 89,
location: 0,
isKeypad: false,
});
expect(sendInputEvent.mock.calls.map(([input]) => input)).toEqual([
{ type: "keyDown", keyCode: "y", modifiers: [], skipIfUnhandled: true },
{ type: "keyUp", keyCode: "y", modifiers: [], skipIfUnhandled: true },
]);
expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
enabled: false,
});
expect(restoreFocus).toHaveBeenCalledTimes(2);
expect(
sendCommand.mock.calls.filter(
([method, params]) =>
method === "Input.dispatchKeyEvent" && params?.["type"] === "keyUp",
),
).toHaveLength(1);
expect(focus).not.toHaveBeenCalled();
expect(restoreFocus).not.toHaveBeenCalled();

sendCommand.mockClear();
sendInputEvent.mockClear();
failKeyDown = false;
yield* manager.automationPress("tab_input", { key: "!" });
expect(sendCommand).toHaveBeenCalledWith("Input.dispatchKeyEvent", {
type: "keyDown",
key: "!",
code: "Digit1",
modifiers: 0,
windowsVirtualKeyCode: 49,
location: 0,
isKeypad: false,
text: "!",
unmodifiedText: "!",
expect(sendInputEvent.mock.calls.map(([input]) => input)).toEqual([
{ type: "keyDown", keyCode: "!", modifiers: [], skipIfUnhandled: true },
{ type: "char", keyCode: "!", modifiers: [], skipIfUnhandled: true },
{ type: "keyUp", keyCode: "!", modifiers: [], skipIfUnhandled: true },
]);
expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
enabled: false,
});
expect(restoreFocus).toHaveBeenCalledTimes(3);
expect(focus).not.toHaveBeenCalled();
expect(restoreFocus).not.toHaveBeenCalled();
}),
),
);
Expand Down
169 changes: 112 additions & 57 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ import {
} from "./GuestProtocol.ts";
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
import {
makePreviewAutomationKeySequence,
previewAutomationEditingCommandExpression,
} from "./PreviewKeyboard.ts";
import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";

export type PreviewNavStatus =
Expand Down Expand Up @@ -1410,7 +1413,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
tabId: string,
wc: Electron.WebContents,
action: string,
use: (send: SendCommand, sendCleanup: SendCommand) => Effect.Effect<A, PreviewManagerError>,
use: (
send: SendCommand,
sendCleanup: SendCommand,
checkControl: Effect.Effect<void, PreviewManagerError>,
) => Effect.Effect<A, PreviewManagerError>,
) {
const sequence = yield* nextCounter(actionSequenceRef);
const startedAt = yield* currentIso;
Expand All @@ -1426,28 +1433,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const control = yield* ensureControlSession(wc);
const execute = Effect.fn("PreviewManager.executeControlAction")(function* () {
yield* update(tabId, { controller: "agent" });
const checkControl = Effect.gen(function* () {
const currentEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
if (currentEpoch !== epoch) {
return yield* new PreviewAutomationControlInterruptedError({
operation: action,
tabId,
webContentsId: wc.id,
});
}
});
const send: SendCommand = Effect.fn("PreviewManager.sendCommand")(
function* (method, commandParams) {
const before = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
if (before !== epoch) {
return yield* new PreviewAutomationControlInterruptedError({
operation: action,
tabId,
webContentsId: wc.id,
});
}
yield* checkControl;
const result = yield* attemptPromise(
{ operation: `${action}.${method}`, tabId, webContentsId: wc.id },
() => control.debugger.sendCommand(method, commandParams),
);
const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
if (after !== epoch) {
return yield* new PreviewAutomationControlInterruptedError({
operation: action,
tabId,
webContentsId: wc.id,
});
}
yield* checkControl;
return result;
},
);
Expand All @@ -1466,7 +1469,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
},
);
return yield* use(send, sendCleanup);
return yield* use(send, sendCleanup, checkControl);
});
const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (
exit: Exit.Exit<A, PreviewManagerError>,
Expand Down Expand Up @@ -3918,59 +3921,111 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
input: PreviewAutomationPressInput,
send: SendCommand,
sendCleanup: SendCommand,
checkControl: Effect.Effect<void, PreviewManagerError>,
) {
yield* prepareAutomationInput(send, false);
const keySequence = makePreviewAutomationKeySequence(input, {
isMac: hostPlatform === "darwin",
});
const previouslyFocused = yield* attempt(
{ operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
() => webContents.getFocusedWebContents(),
);
let keyDownAttempted = false;
const releaseInput = Effect.gen(function* () {
if (keyDownAttempted) {
yield* sendCleanup("Input.dispatchKeyEvent", keySequence.keyUp).pipe(Effect.ignore);
}
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);
}
});

// Focus the guest WebContents itself, not its containing BrowserWindow. This
// activates native keyboard behavior for hidden/background previews without
// changing which thread is mounted in the UI. Restore the previous renderer
// after dispatch so automation never leaves the app's input focus behind.
// CDP keyboard dispatch follows the embedder's focused renderer, and
// WebContents.focus() is a no-op for webview guests. Native input targets
// this guest's widget directly, so Enter cannot submit the host composer.
yield* Effect.gen(function* () {
yield* attempt(
{ operation: "automationPress.focusWebContents", tabId, webContentsId: wc.id },
() => wc.focus(),
);
yield* send("Page.bringToFront");
if (keySequence.commands?.length) {
// Follow active iframe WindowProxy identities, which remain comparable
// across origins even when the host holds native focus.
let frame = yield* attempt(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
() => wc.mainFrame,
);
while (true) {
yield* checkControl;
const childIndex = yield* attemptPromise(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
async () => {
const index: unknown = await frame.executeJavaScript(`(() => {
let element = document.activeElement;
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
if (element?.tagName !== "IFRAME") return -1;
for (let index = 0; index < window.length; index++) {
if (window[index] === element.contentWindow) return index;
}
throw new Error("The focused preview iframe is unavailable.");
})()`);
if (typeof index !== "number" || !Number.isInteger(index)) {
throw new Error("The focused preview iframe index is invalid.");
}
return index;
},
);
yield* checkControl;
if (childIndex === -1) {
yield* attemptPromise(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
() =>
frame.executeJavaScript(
previewAutomationEditingCommandExpression(input, keySequence),
true,
),
);
yield* checkControl;
return;
}
const children = yield* attempt(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
() => frame.frames,
);
let activeChild: Electron.WebFrameMain | undefined;
for (const child of children) {
yield* checkControl;
const matches = yield* attemptPromise(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
() => child.executeJavaScript(`window.parent[${childIndex}] === window`),
);
yield* checkControl;
if (matches) {
activeChild = child;
break;
}
}
frame = yield* attempt(
{ operation: "automationPress.editFocusedFrame", tabId, webContentsId: wc.id },
() => {
if (!activeChild) throw new Error("The focused preview iframe is unavailable.");
return activeChild;
},
);
}
}
yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
yield* checkControl;
yield* expectAgentInput(tabId, keySequence.signal);
keyDownAttempted = true;
yield* send("Input.dispatchKeyEvent", keySequence.keyDown);
}).pipe(Effect.ensuring(releaseInput));
yield* attempt(
{ operation: "automationPress.sendInputEvent", tabId, webContentsId: wc.id },
() => {
try {
wc.sendInputEvent(keySequence.keyDown);
if (keySequence.char) wc.sendInputEvent(keySequence.char);
Comment thread
maria-rcks marked this conversation as resolved.
Outdated
} finally {
wc.sendInputEvent(keySequence.keyUp);
}
},
);
yield* checkControl;
}).pipe(
Effect.ensuring(
sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(Effect.ignore),
),
);
});

const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
tabId: string,
input: PreviewAutomationPressInput,
) {
const wc = yield* requireWebContents(tabId);
yield* withControlSession(tabId, wc, "press", (send, sendCleanup) =>
performAutomationPress(tabId, wc, input, send, sendCleanup),
yield* withControlSession(tabId, wc, "press", (send, sendCleanup, checkControl) =>
performAutomationPress(tabId, wc, input, send, sendCleanup, checkControl),
);
});

Expand Down
Loading
Loading