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[0], { name }) : parent.getByRole(role as Parameters[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[0], { name }) : page.getByRole(role as Parameters[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 { 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; }