Resolve a named selector to a Playwright-compatible selector string.
Priority: css registry → xpath registry.
/
function resolveSelector(world, name) {
const trimmed = name.trim();
if (world.__selectorsCss && world.__selectorsCss[trimmed]) {
return world.__selectorsCss[trimmed];
}
if (world.__selectorsXpath && world.__selectorsXpath[trimmed]) {
const xp = world.__selectorsXpath[trimmed];
return xp.startsWith('xpath=') ? xp : xpath=${xp};
}
// Suggest the closest registered name when one exists. Cheap Levenshtein
// by character — good enough for short selector names.
const known = [
...Object.keys(world.__selectorsCss || {}),
...Object.keys(world.__selectorsXpath || {}),
];
let best = null; let bestDist = Infinity;
for (const k of known) {
const dist = (function lev(a, b) {
const m = a.length, n = b.length;
if (!m) return n; if (!n) return m;
const d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) d[i][0] = i;
for (let j = 0; j <= n; j++) d[0][j] = j;
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) {
d[i][j] = a[i - 1] === b[j - 1] ? d[i - 1][j - 1] : 1 + Math.min(d[i - 1][j], d[i][j - 1], d[i - 1][j - 1]);
}
return d[m][n];
})(trimmed.toLowerCase(), k.toLowerCase());
if (dist < bestDist) { bestDist = dist; best = k; }
}
const suggestion = best && bestDist <= Math.max(2, Math.floor(trimmed.length / 3))
? \n Did you mean "${best}"?
: '';
const examples = known.length
? \n Registered names: ${known.slice(0, 8).join(', ')}${known.length > 8 ? ', ...' : ''}
: '\n No selectors registered yet.';
throw friendly(
Unknown selector "${trimmed}".${suggestion}${examples}\n +
Register one with:\n +
Given I define css selectors:\n +
| name | css |\n +
When I add "${trimmed}" selector for "<css>" css selector\n +
or load a JSON preset via worldParameters.selectors.files.
);
}
function parseNames(text) {
return text.split(/\s*(?:,|and)\s*/).map(s => s.trim()).filter(Boolean);
}
// ---------------------------------------------------------------------------
// Geometry helpers using Playwright locator.boundingBox()
// ---------------------------------------------------------------------------
async function getGeometry(page, selector) {
const loc = page.locator(selector).first();
try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch { /* may already be visible */ }
const box = await loc.boundingBox();
if (!box) return null;
const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
// z-index still requires evaluate — no native Playwright API for computed style.
const zIndex = await loc.evaluate(el => {
let z = 0;
let node = el;
while (node && node !== document.body) {
const cs = window.getComputedStyle(node);
if (cs.position !== 'static' && cs.zIndex !== 'auto') {
const n = parseInt(cs.zIndex, 10);
if (!isNaN(n)) z = n;
}
node = node.parentElement;
}
return z;
});
return { top: box.y + scroll.y, left: box.x + scroll.x, width: box.width, height: box.height, zIndex };
}
async function assertPosition(page, position, c1Name, c1Sel, c2Name, c2Sel) {
const g1 = await getGeometry(page, c1Sel);
if (!g1) throw friendly(Cannot get bounding box for "${c1Name}" (${c1Sel}) — element not visible);
const g2 = await getGeometry(page, c2Sel);
if (!g2) throw friendly(Cannot get bounding box for "${c2Name}" (${c2Sel}) — element not visible);
let pass = false;
switch (position) {
case 'above': pass = g2.top >= g1.top + g1.height; break;
case 'below': pass = g1.top >= g2.top + g2.height; break;
case 'left': pass = g1.left + g1.width <= g2.left; break;
case 'right': pass = g1.left >= g2.left + g2.width; break;
case 'inside':
pass = g1.top >= g2.top && g1.top + g1.height <= g2.top + g2.height &&
g1.left >= g2.left && g1.left + g1.width <= g2.left + g2.width;
break;
case 'outside':
pass = g1.top <= g2.top && g1.top + g1.height >= g2.top + g2.height &&
g1.left <= g2.left && g1.left + g1.width >= g2.left + g2.width;
break;
case 'over': {
const intersects = !(g1.left >= g2.left + g2.width || g1.left + g1.width <= g2.left ||
g1.top >= g2.top + g2.height || g1.top + g1.height <= g2.top);
pass = intersects && g1.zIndex <= g2.zIndex;
break;
}
}
return pass;
}
async function dispatcher(world, position, subjectText, othersText, negate = false) {
const subjects = parseNames(subjectText);
const others = parseNames(othersText);
const errors = [];
for (const subjectName of subjects) {
const subjectSel = resolveSelector(world, subjectName);
for (const otherName of others) {
const otherSel = resolveSelector(world, otherName);
try {
const pass = await assertPosition(world.page, position, subjectName, subjectSel, otherName, otherSel);
if (!pass && !negate) errors.push("${subjectName}" is not ${position} "${otherName}");
else if (pass && negate) errors.push("${subjectName}" is ${position} "${otherName}" (expected not to be));
} catch (e) { errors.push(e.message); }
}
}
if (errors.length > 0) throw friendly(errors.join('\n'));
}
// ===========================================================================
// STEP DEFINITIONS — Advanced selector system
// ===========================================================================
/**
Register a named CSS selector at runtime.
Step pattern
When (I |we )*add "([^"]*)" selector for "([^"]*)" css selectorExamples
When I add "mobile logo" selector for "header img#logo" css selectorWhen I add "breadcrumb" selector for ".breadcrumb" css selectorWhen I add "breadcrumb first link" selector for ".breadcrumb li:nth-child(1) a" css selectorWhen I add "cta button" selector for ".cta .btn-primary" css selectorWhen I add "page header" selector for "header.page-header" css selectorWhen I add "main nav" selector for "nav[role='navigation']" css selectorWhen I add "hero image" selector for ".hero img" css selectorWhen I add "footer links" selector for "footer a" css selectorWhen I add "search field" selector for "input[type='search']" css selectorWhen I add "submit button" selector for "button[type='submit']" css selectorLater override same name → latest wins:
When I add "target" selector for "h1" css selector
And I add "target" selector for "ul" css selectorComponent (CSS) takes precedence over CSS registry with same name:
When I add "shared" selector for "ul" css selector
And I define css selectors:
| shared | h1 |Combine with XPath registration and use together:
When I add "cta" selector for ".cta" css selector
And I add "cta link" selector for "//a[contains(@class,'cta')]" xpath selector