// @ts-ignore import { parse } from './parser'; // generated by peggy from parser.peggy // ---------- AST Types (mirror the grammar; keep minimal) ---------- type RoleSelector = { type: 'Selector'; mode: 'role'; role: string; // any ARIA role, or the specials: field | image | text name: string | null; // the quoted name after role }; type TagSelector = { type: 'Selector'; mode: 'tag'; tag: string; // e.g., section, form, button }; type RawSelector = { type: 'Selector'; mode: 'raw'; raw: string; // backticked Playwright selector, without the backticks }; type DateSelector = { type: 'Selector'; mode: 'date'; name: string; }; type Selector = RoleSelector | TagSelector | RawSelector | DateSelector; type Predicate = { type: 'HasDescendant'; selector: Selector }; type WithExpr = { type: 'With'; selector: Selector; include: Predicate[]; // from `with ` exclude: Predicate[]; // from `without ` }; type Expr = { type: 'Within'; base: WithExpr; ancestors: Selector[]; // outermost → innermost containers from chained `within` }; // ---------- Escapers ---------- function escapeForTextSelector(text: string, exact = false): string { return `${JSON.stringify(text)}${exact ? 's' : 'i'}`; } function escapeForAttributeSelector(value: string, exact = false): string { return `"${value.trim().replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`; } function escapeForCssAttributeValue(value: string): string { return value.trim().replace(/\\/g, '\\\\').replace(/["]/g, '\\"'); } // ---------- Builders for internal engines ---------- function getByAttributeTextSelector(attrName: string, text: string, props = ''): string { return `[${attrName}=${escapeForAttributeSelector(text)}]` + (props ? ` ${props}` : ''); } function getByAltTextSelector(text: string, props = ''): string { return getByAttributeTextSelector('alt', text, props); } function getByFieldSelector(text: string, props = ''): string { const q = escapeForTextSelector(text); return `field=${q}${props ? ` ${props}` : ''}`; } function getByTextSelector(text: string, props = ''): string { // Produce a regex selector for case-insensitive substring matching. // Regex special chars (and the delimiter /) must be escaped. const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '\\/'); return 'text=/' + escaped + '/i' + (props ? ` ${props}` : ''); } function getByDateSelector(text: string): string { return 'date=' + text.trim(); } function getByRoleSelector(role: string, name: string = '', props = ''): string { return `role=${role}` + (name ? ` [name=${escapeForAttributeSelector(name)}]` : '') + (props ? ` ${props}` : ''); } function isIframeRoleSelector(sel: Selector): sel is RoleSelector { return sel.mode === 'role' && sel.role.toLowerCase() === 'iframe' && !!sel.name; } function getByIframeSelector(name: string): string { const value = escapeForCssAttributeValue(name); return `css=iframe:is([title="${value}" i],[name="${value}" i],[aria-label="${value}" i],[id="${value}" i])`; } function compileAncestorSelector(sel: Selector): string { if (isIframeRoleSelector(sel)) { return `${getByIframeSelector(sel.name)} >> internal:control=enter-frame`; } return compileBaseSelector(sel); } // ---------- Core compile helpers ---------- /** * Compile a base selector (no predicates, no ancestors). */ function compileBaseSelector(sel: Selector): string { switch (sel.mode) { case 'raw': return sel.raw; case 'tag': return sel.tag; case 'date': return getByDateSelector(sel.name); case 'role': { const r = sel.role.toLowerCase(); if (r === 'field') { if (!sel.name) throw new Error('`field` requires a label string: field "Name"'); return getByFieldSelector(sel.name); } if (r === 'image') { if (!sel.name) throw new Error('`image` requires an alt string: image "Logo"'); return getByAltTextSelector(sel.name); } if (r === 'text') { if (!sel.name) throw new Error('`text` requires a string: text "Hello"'); return getByTextSelector(sel.name); } // Generic ARIA role return getByRoleSelector(r, sel.name ?? ''); } } } /** * Compile a predicate into ` >> has=...` or ` >> has-not=...`. * We always use the general `has` flavor and stringify the *inner* selector chain, * which matches the constructor pattern you showed. */ function compilePredicates(include: Predicate[], exclude: Predicate[]): string { const parts: string[] = []; for (const p of include) { const inner = compileBaseSelector(p.selector); parts.push(`>> has="${inner}"`); } for (const p of exclude) { const inner = compileBaseSelector(p.selector); parts.push(`>> has-not="${inner}"`); } return parts.length ? ' ' + parts.join(' ') : ''; } /** * Join a chain with Playwright’s engine combinator. */ function joinChain(parts: string[]): string { return parts.filter(Boolean).join(' >> '); } /** * Public API: compile a DSL locator string into a Playwright selector string. */ export function compileLocator(input: string): string { const ast = parse(input) as Expr; // 1) compile ancestor containers (outer → inner) const chain: string[] = ast.ancestors.map(compileAncestorSelector); // 2) compile base const base = compileBaseSelector(ast.base.selector); // 3) add predicates to the *base* (they apply to the current rightmost selector) const pred = compilePredicates(ast.base.include, ast.base.exclude); // 4) build final chain let selector = joinChain([...chain, base]); if (pred) selector += ' ' + pred.trim(); return selector; }