Skip to content
Open
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
68 changes: 68 additions & 0 deletions apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<div id="host"></div><div contenteditable="true"><iframe srcdoc="<input id=inner>"></iframe></div><script>document.getElementById("host").attachShadow({mode:"open"}).innerHTML = "<input id=field>";</script>`,
);
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(`
<table><tr><td>password:</td><td><input id="pw" type="password" aria-label="Password"></td></tr></table>
Expand Down Expand Up @@ -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<unknown>, typed: string[]) =>
({
locator: () => ({ last: () => ({ count: () => new Promise<number>(() => {}) }) }),
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) => ({}));
Expand Down
112 changes: 73 additions & 39 deletions apps/server/src/preview/ServerBrowserPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,53 +800,87 @@ 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<typeof setTimeout> | undefined;
const expired = new Promise<never>((_, 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);
if (locator !== null && input.clear) {
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",
Expand Down
Loading