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
18 changes: 16 additions & 2 deletions packages/extension/tests/frame-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,21 @@ describe("FrameLocator readiness", () => {
const { locator, childFrame } = createFrameLocator(oldSession, getSessionForFrame);

await expect(locator.resolveFrame()).resolves.toBe(childFrame);
expect(waitForLocatorWorld).toHaveBeenNthCalledWith(1, oldSession, "child", 200);
expect(waitForLocatorWorld).toHaveBeenNthCalledWith(2, adoptedSession, "child", 200);
expect(waitForLocatorWorld).toHaveBeenNthCalledWith(
1,
oldSession,
"child",
200,
undefined,
false,
);
expect(waitForLocatorWorld).toHaveBeenNthCalledWith(
2,
adoptedSession,
"child",
200,
undefined,
false,
);
});
});
9 changes: 9 additions & 0 deletions packages/extension/understudy/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export interface CDPSessionLike {
readonly id: string | null;
}

/** Closure errors emitted by this transport, not transient execution-context loss. */
export function isCdpClosedError(error: unknown): error is Error {
return (
error instanceof Error &&
(error.message.startsWith("CDP connection closed:") ||
error.message.startsWith("No Page found for target closed before CDP "))
);
}

export type CdpWebSocketCloseEvent = {
code: number;
reason: string;
Expand Down
113 changes: 111 additions & 2 deletions packages/extension/understudy/deepLocator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Frame } from "./frame.js";
import type { Page } from "./page.js";
import { DeepLocatorDelegate } from "./deepLocator.js";
import { FrameLocator, frameLocatorFromFrame } from "./frameLocator.js";
import { Locator } from "./locator.js";
import { Progress, runWithProgress } from "./progress.js";
import { FrameSelectorResolver } from "./selectorResolver.js";
import { executionContexts } from "./executionContextRegistry.js";

describe("DeepLocatorDelegate match selection", () => {
const createDelegate = () => {
Expand All @@ -26,6 +31,110 @@ describe("DeepLocatorDelegate match selection", () => {
.mockResolvedValue({ objectId: "node-1", nodeId: null });

await expect(locator.resolveNode()).resolves.toEqual({ objectId: "node-1", nodeId: null });
expect(resolveAtIndex).toHaveBeenCalledWith(locator.selectorQuery, 0);
expect(resolveAtIndex).toHaveBeenCalledWith(locator.selectorQuery, 0, undefined);
});
});

describe("locator resolution contexts", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

function createFrames() {
const send = vi.fn(async (method: string) => {
if (method === "DOM.describeNode") return { node: { backendNodeId: 1 } };
if (method === "DOM.getFrameOwner") return { backendNodeId: 1 };
if (method === "Runtime.evaluate") return { result: { objectId: "node" } };
if (method === "DOM.requestNode") return { nodeId: 1 };
return {};
});
const frames = ["root", "middle", "inner"].map((frameId) => {
const frame = {
frameId,
session: { send },
locator: (selector: string) => new Locator(frame, selector),
} as unknown as Frame;
return frame;
});
const [root, middle, inner] = frames;
const page = {
getFullFrameTree: () => ({
frame: { id: root.frameId },
childFrames: [
{
frame: { id: middle.frameId },
childFrames: [{ frame: { id: inner.frameId } }],
},
],
}),
getSessionForFrame: () => root.session,
frameForId: (id: string) => frames.find((frame) => frame.frameId === id),
} as unknown as Page;
vi.spyOn(executionContexts, "waitForLocatorWorld").mockResolvedValue({
kind: "extension",
contextId: 1,
capabilities: { closedShadowRoots: true },
});
return { page, root, inner, send };
}

it.each(["hops", "xpath", "frame locator"] as const)(
"passes one context through nested %s resolution & preserves nth()",
async (kind) => {
vi.useFakeTimers();
const { page, root, inner } = createFrames();
const resolveFrame = vi.spyOn(FrameLocator.prototype, "resolveFrame");
const lookups = (["resolveCss", "resolveText", "resolveXPath"] as const).map((method) =>
vi.spyOn(FrameSelectorResolver.prototype, method),
);
const delegate =
kind === "frame locator"
? frameLocatorFromFrame(page, root, "#outer")
.frameLocator("#inner")
.locator("text=target")
: new DeepLocatorDelegate(
page,
root,
kind === "hops"
? "#outer >> #inner >> #target"
: "xpath=/html/iframe/html/iframe/html/button",
);

await runWithProgress({ name: "resolve", timeout: 100 }, async (progress) => {
const locator = await delegate.nth(1).real(progress);
expect(locator.getFrame()).toBe(inner);
expect(locator.nthIndex).toBe(1);
await expect(locator.resolveNode(progress)).resolves.toEqual({
objectId: "node",
nodeId: 1,
});
expect(resolveFrame.mock.calls).toEqual([[progress], [progress]]);
const calls = lookups.flatMap((lookup) => lookup.mock.calls);
expect(calls).toHaveLength(3);
expect(calls.every(([, , context]) => context === progress)).toBe(true);
expect(progress.remainingMs()).toBe(100);
});
expect(vi.getTimerCount()).toBe(0);
},
);

it("rejects an expired context before resolving a target or sending commands", async () => {
vi.useFakeTimers();
const { page, root, send } = createFrames();
const progress = new Progress("resolve", 100);
await vi.advanceTimersByTimeAsync(100);

await expect(new DeepLocatorDelegate(page, root, "#target").real(progress)).rejects.toBe(
progress.signal.reason,
);
await expect(frameLocatorFromFrame(page, root, "#outer").resolveFrame(progress)).rejects.toBe(
progress.signal.reason,
);
await expect(root.locator("#target").resolveNode(progress)).rejects.toBe(
progress.signal.reason,
);
expect(send).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
});
21 changes: 14 additions & 7 deletions packages/extension/understudy/deepLocator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Locator } from "./locator.js";
import type { Progress } from "./progress.js";
import type { Frame } from "./frame.js";
import type { Page } from "./page.js";
import { FrameLocator, frameLocatorFromFrame } from "./frameLocator.js";
Expand Down Expand Up @@ -54,8 +55,9 @@ export async function deepLocatorThroughIframes(
page: Page,
root: Frame,
xpathOrSelector: string,
progress?: Progress,
): Promise<Locator> {
const target = await resolveDeepXPathTarget(page, root, xpathOrSelector);
const target = await resolveDeepXPathTarget(page, root, xpathOrSelector, progress);
return new Locator(target.frame, target.selector);
}

Expand All @@ -67,7 +69,9 @@ export async function resolveLocatorTarget(
page: Page,
root: Frame,
selectorRaw: string,
progress?: Progress,
): Promise<ResolvedLocatorTarget> {
progress?.throwIfStopped();
const sel = selectorRaw.trim();
const parts = sel
.split(">>")
Expand All @@ -80,14 +84,14 @@ export async function resolveLocatorTarget(
for (let i = 1; i < parts.length - 1; i++) {
fl = fl.frameLocator(parts[i]!);
}
const targetFrame = await fl.resolveFrame();
const targetFrame = await fl.resolveFrame(progress);
return { frame: targetFrame, selector: parts[parts.length - 1]! };
}

// No hops — delegate to XPath-aware deep resolver when needed
const isXPath = sel.startsWith("xpath=") || sel.startsWith("/");
if (isXPath) {
return resolveDeepXPathTarget(page, root, sel);
return resolveDeepXPathTarget(page, root, sel, progress);
}
return { frame: root, selector: sel };
}
Expand All @@ -96,8 +100,9 @@ export async function resolveLocatorWithHops(
page: Page,
root: Frame,
selectorRaw: string,
progress?: Progress,
): Promise<Locator> {
const target = await resolveLocatorTarget(page, root, selectorRaw);
const target = await resolveLocatorTarget(page, root, selectorRaw, progress);
return new Locator(target.frame, target.selector);
}

Expand All @@ -118,8 +123,8 @@ export class DeepLocatorDelegate {
readonly nthIndex: number = -1,
) {}

async real(): Promise<Locator> {
const base = await resolveLocatorWithHops(this.page, this.root, this.selector);
async real(progress?: Progress): Promise<Locator> {
const base = await resolveLocatorWithHops(this.page, this.root, this.selector, progress);
return this.nthIndex < 0 ? base : base.nth(this.nthIndex);
}

Expand Down Expand Up @@ -249,12 +254,14 @@ async function resolveDeepXPathTarget(
page: Page,
root: Frame,
xpathOrSelector: string,
progress?: Progress,
): Promise<ResolvedLocatorTarget> {
progress?.throwIfStopped();
const plan = planDeepXPathTarget(xpathOrSelector);
let fl: FrameLocator | undefined;
for (const hop of plan.frameHopSelectors) {
fl = fl ? fl.frameLocator(hop) : frameLocatorFromFrame(page, root, hop);
}
const targetFrame = fl ? await fl.resolveFrame() : root;
const targetFrame = fl ? await fl.resolveFrame(progress) : root;
return { frame: targetFrame, selector: plan.finalSelector };
}
Loading
Loading