|
|
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;
|
|
|
}
|