You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

107 lines
3.4 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Locator, Page } from "playwright";
function chainPartLocator(parent: Locator, part: string): Locator {
const nthMatch = part.match(/^nth=(\d+)$/);
if (nthMatch) {
return parent.nth(Number(nthMatch[1]));
}
if (part === "first") {
return parent.first();
}
const roleMatch = part.match(/^role=(\w+)(?:\[name="([^"]*)"\])?$/);
if (roleMatch) {
const [, role, name] = roleMatch;
return name
? parent.getByRole(role as Parameters<Locator["getByRole"]>[0], { name })
: parent.getByRole(role as Parameters<Locator["getByRole"]>[0]);
}
if (part.startsWith("text=")) {
const text = part.slice(5);
if (text.startsWith("/") && text.endsWith("/")) {
return parent.getByText(new RegExp(text.slice(1, -1)));
}
return parent.getByText(text);
}
if (part.startsWith("placeholder=")) {
const ph = part.slice(12).replace(/^"|"$/g, "");
return parent.getByPlaceholder(ph);
}
if (part.startsWith("filter:text=")) {
const textPart = part.slice(12);
if (textPart.startsWith("/") && textPart.endsWith("/")) {
return parent.locator("div").filter({ hasText: new RegExp(textPart.slice(1, -1)) });
}
return parent.locator("div").filter({ hasText: textPart });
}
return parent.locator(part);
}
function applyChain(locator: Locator, chain: string): Locator {
const parts = chain.split(">>").map((p) => p.trim()).filter(Boolean);
let current = locator;
for (const part of parts) {
current = chainPartLocator(current, part);
}
return current;
}
/**
* 将 .env 中的 selector 规格解析为 Playwright Locator。
* 支持CSS、`role=`、`text=`、`placeholder=`、`filter:text=`,以及 `>>nth=N` 链。
*/
export function pageLocator(page: Page, spec: string): Locator {
const [baseSpec, ...chainParts] = spec.split(">>").map((p) => p.trim());
const trimmed = baseSpec ?? "";
let base: Locator;
const roleMatch = trimmed.match(/^role=(\w+)(?:\[name="([^"]*)"\])?$/);
if (roleMatch) {
const [, role, name] = roleMatch;
base = name
? page.getByRole(role as Parameters<Page["getByRole"]>[0], { name })
: page.getByRole(role as Parameters<Page["getByRole"]>[0]);
} else if (trimmed.startsWith("text=")) {
const text = trimmed.slice(5);
if (text.startsWith("/") && text.endsWith("/")) {
base = page.getByText(new RegExp(text.slice(1, -1)));
} else {
base = page.getByText(text);
}
} else if (trimmed.startsWith("placeholder=")) {
const ph = trimmed.slice(12).replace(/^"|"$/g, "");
base = page.getByPlaceholder(ph);
} else if (trimmed.startsWith("filter:text=")) {
const textPart = trimmed.slice(12);
if (textPart.startsWith("/") && textPart.endsWith("/")) {
base = page.locator("div").filter({ hasText: new RegExp(textPart.slice(1, -1)) });
} else {
base = page.locator("div").filter({ hasText: textPart });
}
} else {
base = page.locator(trimmed);
}
if (chainParts.length === 0) {
return base;
}
return applyChain(base, chainParts.join(" >> "));
}
/** 取 spec 匹配到的第一个可见元素 */
export async function firstVisibleLocator(
page: Page,
spec: string,
): Promise<Locator | null> {
const root = pageLocator(page, spec);
const count = await root.count().catch(() => 0);
for (let i = 0; i < count; i++) {
const item = root.nth(i);
if (await item.isVisible().catch(() => false)) {
return item;
}
}
return null;
}