diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts
index 021cb70fb0aeb..bdef9483f17df 100644
--- a/apps/server/src/preview/ServerBrowserPage.test.ts
+++ b/apps/server/src/preview/ServerBrowserPage.test.ts
@@ -214,6 +214,25 @@ describe("server browser element refs", () => {
expect(await page.getByRole("textbox", { name: "parent" }).inputValue()).toBe("");
});
+ it("types after a click by coordinates into a shadow-root field, and refuses a field inside an editable frame", async () => {
+ await page.setContent(
+ `
`,
+ );
+ const field = page.locator("#host #field");
+ const box = (await field.boundingBox())!;
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
+ await ServerBrowserPage.type(page, { text: "shadow" });
+ expect(await field.inputValue()).toBe("shadow");
+
+ // Refused for now: focus inside a frame cannot be checked from the top document.
+ const inner = page.frameLocator("iframe").locator("#inner");
+ await inner.click();
+ await expect(ServerBrowserPage.type(page, { text: "frame" })).rejects.toMatchObject({
+ tag: "PreviewAutomationTargetNotEditableError",
+ });
+ expect(await inner.inputValue()).toBe("");
+ });
+
it("shows dotted fields as dots wherever their value appears, keeping text values and refs", async () => {
await page.setContent(`
@@ -717,6 +736,55 @@ describe("server browser evaluate", () => {
});
});
+describe("server browser type", () => {
+ // As on a desktop tab whose Playwright utility world never answers.
+ const stalledPage = (focusAnswer: () => Promise, typed: string[]) =>
+ ({
+ locator: () => ({ last: () => ({ count: () => new Promise(() => {}) }) }),
+ evaluate: focusAnswer,
+ keyboard: { insertText: async (text: string) => void typed.push(text) },
+ }) as unknown as Page;
+
+ it("types into the focused field when Playwright's utility world never answers", async () => {
+ vi.useFakeTimers();
+ try {
+ const typed: string[] = [];
+ let outcome: unknown = "pending";
+ const page = stalledPage(async () => true, typed);
+ void ServerBrowserPage.type(page, { text: "hello", timeoutMs: 300 }).then(
+ () => (outcome = "typed"),
+ (error: unknown) => (outcome = error),
+ );
+ await vi.advanceTimersByTimeAsync(300);
+ expect(outcome).toBe("typed");
+ expect(typed).toEqual(["hello"]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stops at its timeout without typing when the page never answers", async () => {
+ vi.useFakeTimers();
+ try {
+ const typed: string[] = [];
+ let outcome: unknown = "pending";
+ const page = stalledPage(() => new Promise(() => {}), typed);
+ void ServerBrowserPage.type(page, { text: "hello", timeoutMs: 300 }).then(
+ () => (outcome = "typed"),
+ (error: unknown) => (outcome = error),
+ );
+ await vi.advanceTimersByTimeAsync(300);
+ expect(outcome).toMatchObject({
+ tag: "PreviewAutomationTimeoutError",
+ detail: { timeoutMs: 300 },
+ });
+ expect(typed).toEqual([]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
describe("server browser unresponsive script", () => {
it("leaves a page that answers alone", async () => {
const send = vi.fn(async (_method: string) => ({}));
diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts
index d3f7bf1d7c3f6..ef91254c75218 100644
--- a/apps/server/src/preview/ServerBrowserPage.ts
+++ b/apps/server/src/preview/ServerBrowserPage.ts
@@ -800,6 +800,76 @@ const clickTarget = async (
return point;
};
+type FocusCandidate = {
+ readonly tagName: string;
+ readonly type?: string;
+ readonly disabled?: boolean;
+ readonly readOnly?: boolean;
+ readonly isContentEditable?: boolean;
+ readonly shadowRoot?: { readonly activeElement: FocusCandidate | null } | null;
+ readonly focus?: () => void;
+ getRootNode(): { readonly activeElement?: FocusCandidate | null };
+ contains(other: FocusCandidate): boolean;
+};
+
+// Serialized into the page's main world, so it must not reference module scope.
+const focusTextTarget = (target: FocusCandidate | null) => {
+ let element =
+ target ??
+ (globalThis as unknown as { document: { activeElement: FocusCandidate | null } }).document
+ .activeElement;
+ if (target === null) {
+ while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
+ // Focus inside a frame cannot be checked from here, and an editable frame would pass as contentEditable.
+ if (element && ["IFRAME", "FRAME", "FENCEDFRAME", "OBJECT", "EMBED"].includes(element.tagName))
+ return false;
+ }
+ if (element == null) return false;
+ // Require an enabled, focused text target to avoid losing input or sending it to the previous field.
+ const nonText = [
+ "button",
+ "checkbox",
+ "color",
+ "file",
+ "hidden",
+ "image",
+ "radio",
+ "range",
+ "reset",
+ "submit",
+ ];
+ const textControl =
+ element.tagName === "TEXTAREA" ||
+ (element.tagName === "INPUT" && !nonText.includes(element.type ?? "text"));
+ if (!(textControl || element.isContentEditable) || element.disabled || element.readOnly) {
+ return false;
+ }
+ element.focus?.();
+ const active = element.getRootNode().activeElement;
+ return active != null && (active === element || element.contains(active));
+};
+
+// A `*:focus` locator needs Playwright's utility world, which some desktop tabs never get.
+const focusedTextField = async (page: Page, timeout: number) => {
+ let timer: ReturnType | undefined;
+ const expired = new Promise((_, reject) => {
+ timer = setTimeout(
+ () =>
+ reject(
+ new ServerBrowserOperationError({
+ tag: "PreviewAutomationTimeoutError",
+ message: `The page did not report its focused element within ${timeout}ms, so no text was typed.`,
+ detail: { timeoutMs: timeout },
+ }),
+ ),
+ timeout,
+ );
+ });
+ return await Promise.race([page.evaluate(focusTextTarget, null), expired]).finally(() =>
+ clearTimeout(timer),
+ );
+};
+
export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const locator = targetLocator(page, input);
@@ -807,46 +877,10 @@ export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
await locator.fill(input.text, { timeout });
return;
}
- // A shadow host also matches :focus; use its innermost focused descendant.
- const target = locator ?? page.locator("*:focus").last();
const focused =
- (locator !== null || (await target.count()) > 0) &&
- (await target.evaluate(
- (element) => {
- // Require an enabled, focused text target to avoid losing input or sending it to the previous field.
- const control = element as unknown as {
- readonly type?: string;
- readonly disabled?: boolean;
- readonly readOnly?: boolean;
- readonly isContentEditable?: boolean;
- readonly focus?: () => void;
- };
- const nonText = [
- "button",
- "checkbox",
- "color",
- "file",
- "hidden",
- "image",
- "radio",
- "range",
- "reset",
- "submit",
- ];
- const textControl =
- element.tagName === "TEXTAREA" ||
- (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text"));
- if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) {
- return false;
- }
- control.focus?.();
- const root = element.getRootNode() as { readonly activeElement?: typeof element | null };
- const active = root.activeElement;
- return active != null && (active === element || element.contains(active));
- },
- undefined,
- { timeout },
- ));
+ locator === null
+ ? await focusedTextField(page, timeout)
+ : await locator.evaluate(focusTextTarget, undefined, { timeout });
if (focused !== true) {
throw new ServerBrowserOperationError({
tag: "PreviewAutomationTargetNotEditableError",