@validate.qa/runner 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +165 -152
  2. package/dist/cli.mjs +165 -152
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1029,9 +1029,39 @@ var require_mobile_source_parser = __commonJS({
1029
1029
  function isEditableElement(el) {
1030
1030
  return EDITABLE_CLASS_RE.test(el.role.trim());
1031
1031
  }
1032
+ function xpathAttrLiteral(value) {
1033
+ if (!value.includes("'"))
1034
+ return `'${value}'`;
1035
+ if (!value.includes('"'))
1036
+ return `"${value}"`;
1037
+ return `concat('${value.split("'").join(`', "'", '`)}')`;
1038
+ }
1039
+ function semanticXPath(el, platform3) {
1040
+ const cls = nonBlank(el.role) ?? "*";
1041
+ const a11y = nonBlank(el.accessibilityId);
1042
+ if (a11y) {
1043
+ return platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]`;
1044
+ }
1045
+ const rid = nonBlank(el.resourceId);
1046
+ if (platform3 === "ANDROID" && rid)
1047
+ return `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]`;
1048
+ const text = nonBlank(el.text);
1049
+ if (text && !isEditableElement(el)) {
1050
+ return platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]`;
1051
+ }
1052
+ return void 0;
1053
+ }
1054
+ function nonBlank(value) {
1055
+ if (typeof value !== "string")
1056
+ return void 0;
1057
+ const trimmed = value.trim();
1058
+ return trimmed.length > 0 ? trimmed : void 0;
1059
+ }
1032
1060
  function buildLocatorBundle(el, platform3) {
1033
1061
  const bundle = {
1034
- xpath: el.xpath,
1062
+ // Attribute-anchored xpath when the element has identity; the absolute
1063
+ // indexed path only for identity-less elements (nothing else can find them).
1064
+ xpath: semanticXPath(el, platform3) ?? el.xpath,
1035
1065
  className: el.role
1036
1066
  };
1037
1067
  if (el.accessibilityId)
@@ -1544,7 +1574,7 @@ function buildStepTarget(metadata, selector) {
1544
1574
  locatorBundle.xpath,
1545
1575
  selector
1546
1576
  ];
1547
- const fallbacks = fallbackCandidates.map(nonEmpty).filter((value, index, list) => typeof value === "string" && value !== primary && list.indexOf(value) === index);
1577
+ const fallbacks = fallbackCandidates.map(nonEmpty).filter((value, index, list) => typeof value === "string" && value !== primary && list.indexOf(value) === index).filter((value) => !value.startsWith("/hierarchy") && !(value.startsWith("/") && value.length > 120));
1548
1578
  return {
1549
1579
  primary,
1550
1580
  fallbacks,
@@ -1675,8 +1705,6 @@ function selectorCandidatesForStep(step) {
1675
1705
  candidates.push(`id=${bundle.resourceId}`);
1676
1706
  if (nonEmpty(bundle.xpath))
1677
1707
  candidates.push(bundle.xpath);
1678
- if (nonEmpty(bundle.className))
1679
- candidates.push(`class=${bundle.className}`);
1680
1708
  }
1681
1709
  if (nonEmpty(target.primary))
1682
1710
  candidates.push(selectorForValue(target.primary));
@@ -1684,7 +1712,7 @@ function selectorCandidatesForStep(step) {
1684
1712
  if (nonEmpty(fallback))
1685
1713
  candidates.push(selectorForValue(fallback));
1686
1714
  }
1687
- return candidates.filter((value, index, list) => list.indexOf(value) === index);
1715
+ return candidates.filter((value) => !value.startsWith("/hierarchy") && !(value.startsWith("/") && !value.startsWith("//") && value.length > 120)).filter((value, index, list) => list.indexOf(value) === index).slice(0, 4);
1688
1716
  }
1689
1717
  function buildAppiumCode(testName, target, steps) {
1690
1718
  const appId = target?.appId ?? "com.example.app";
@@ -1694,74 +1722,118 @@ function buildAppiumCode(testName, target, steps) {
1694
1722
  const platformVersion = target?.platformVersion;
1695
1723
  const launchMode = target?.launchMode;
1696
1724
  const deeplink = target?.deeplink;
1697
- const hasSwipe = steps.some((s) => s.action === "swipe" || s.action === "scroll");
1725
+ const boundsOf = (step) => step.metadata?.bounds;
1726
+ const centerOf = (b) => ({ x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) });
1727
+ const atLiteral = (b) => {
1728
+ const c = centerOf(b);
1729
+ return `{ x: ${c.x}, y: ${c.y} }`;
1730
+ };
1731
+ const used = {
1732
+ find: false,
1733
+ tap: false,
1734
+ tapAt: false,
1735
+ typeInto: false,
1736
+ clearField: false,
1737
+ expectVisible: false,
1738
+ expectText: false,
1739
+ swipe: false,
1740
+ wait: false
1741
+ };
1742
+ for (const step of steps) {
1743
+ const hasSelector = selectorCandidatesForStep(step).length > 0;
1744
+ const bounds = boundsOf(step);
1745
+ switch (step.action) {
1746
+ case "tap":
1747
+ if (hasSelector) {
1748
+ used.tap = true;
1749
+ used.find = true;
1750
+ if (bounds)
1751
+ used.tapAt = true;
1752
+ } else if (bounds)
1753
+ used.tapAt = true;
1754
+ break;
1755
+ case "type":
1756
+ used.typeInto = true;
1757
+ if (hasSelector)
1758
+ used.find = true;
1759
+ if (bounds)
1760
+ used.tapAt = true;
1761
+ break;
1762
+ case "clear":
1763
+ used.clearField = true;
1764
+ if (hasSelector)
1765
+ used.find = true;
1766
+ if (bounds)
1767
+ used.tapAt = true;
1768
+ break;
1769
+ case "assertVisible":
1770
+ if (hasSelector) {
1771
+ used.expectVisible = true;
1772
+ used.find = true;
1773
+ }
1774
+ break;
1775
+ case "assertText":
1776
+ if (hasSelector) {
1777
+ used.expectText = true;
1778
+ used.find = true;
1779
+ }
1780
+ break;
1781
+ case "swipe":
1782
+ case "scroll":
1783
+ used.swipe = true;
1784
+ break;
1785
+ case "waitForElement":
1786
+ if (hasSelector) {
1787
+ used.wait = true;
1788
+ used.find = true;
1789
+ }
1790
+ break;
1791
+ default:
1792
+ break;
1793
+ }
1794
+ }
1795
+ const helperLines = [];
1796
+ if (used.find)
1797
+ helperLines.push(`// First selector that resolves wins \u2014 the runner's exact fallback chain.`, `async function find(driver: WebdriverIO.Browser, selectors: string[]) {`, ` for (const sel of selectors) {`, ` try { const el = await driver.$(sel); if (await el.isExisting()) return el; } catch { /* try next */ }`, ` }`, ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors));`, `}`, ``);
1798
+ if (used.tapAt)
1799
+ helperLines.push(`async function tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`, ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [`, ` { type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 },`, ` ] }]);`, `}`, ``);
1800
+ if (used.tap)
1801
+ helperLines.push(`// Locator-first tap; falls back to the recorded coordinates like the runner.`, `async function tap(driver: WebdriverIO.Browser, selectors: string[], at?: { x: number; y: number }) {`, used.tapAt ? ` try { await (await find(driver, selectors)).click(); } catch (err) { if (!at) throw err; await tapAt(driver, at.x, at.y); }` : ` await (await find(driver, selectors)).click();`, `}`, ``);
1802
+ if (used.typeInto)
1803
+ helperLines.push(`// addValue (append) matches replay exactly; with recorded coordinates it`, `// taps to focus first (placeholder locators go stale once the keyboard opens).`, `async function typeInto(driver: WebdriverIO.Browser, selectors: string[], text: string, at?: { x: number; y: number }) {`, ...used.tapAt ? [
1804
+ ` if (at) {`,
1805
+ ` try {`,
1806
+ ` await tapAt(driver, at.x, at.y);`,
1807
+ ` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`,
1808
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1809
+ ` return;`,
1810
+ ` } catch { /* fall through to locator path */ }`,
1811
+ ` }`
1812
+ ] : [], ` await (await find(driver, selectors)).addValue(text);`, `}`, ``);
1813
+ if (used.clearField)
1814
+ helperLines.push(`async function clearField(driver: WebdriverIO.Browser, selectors: string[], at?: { x: number; y: number }) {`, ...used.tapAt ? [
1815
+ ` if (at) {`,
1816
+ ` try {`,
1817
+ ` await tapAt(driver, at.x, at.y);`,
1818
+ ` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`,
1819
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1820
+ ` return;`,
1821
+ ` } catch { /* fall through to locator path */ }`,
1822
+ ` }`
1823
+ ] : [], ` await (await find(driver, selectors)).clearValue();`, `}`, ``);
1824
+ if (used.expectVisible)
1825
+ helperLines.push(`async function expectVisible(driver: WebdriverIO.Browser, selectors: string[]) {`, ` if (!(await (await find(driver, selectors)).isDisplayed())) {`, ` throw new Error('Assertion failed: element not displayed: ' + JSON.stringify(selectors));`, ` }`, `}`, ``);
1826
+ if (used.expectText)
1827
+ helperLines.push(`// Contains-match over text-like attributes \u2014 mirrors the runtime executor.`, `async function expectText(driver: WebdriverIO.Browser, selectors: string[], expected: string) {`, ` if (expected.length === 0) throw new Error('assertText step is missing expected text');`, ` const el = await find(driver, selectors);`, ` const values: string[] = [];`, ` const push = (v: unknown) => { if (typeof v === 'string' && v.trim() && !values.includes(v.trim())) values.push(v.trim()); };`, ` push(await el.getText().catch(() => ''));`, ` for (const attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) push(await el.getAttribute(attr).catch(() => ''));`, ` if (!values.some((v) => v.includes(expected))) {`, ` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(expected) + ' but got ' + JSON.stringify(values));`, ` }`, `}`, ``);
1828
+ if (used.swipe)
1829
+ helperLines.push(`// Centred W3C swipe with the executor's geometry (full distance, clamped to 80% of the axis).`, `async function swipe(driver: WebdriverIO.Browser, direction: 'up' | 'down' | 'left' | 'right', distance: number) {`, ` let rect: { width: number; height: number } | undefined;`, ` for (let i = 0; i < 3 && !rect; i++) {`, ` try { const r = await driver.getWindowRect(); if (r && r.width > 0 && r.height > 0) rect = r; } catch { await driver.pause(Math.pow(2, i) * 100); }`, ` }`, ` if (!rect) throw new Error('Could not determine device window size');`, ` const vertical = direction === 'up' || direction === 'down';`, ` const travel = Math.min(distance, Math.max(1, Math.round((vertical ? rect.height : rect.width) * 0.8)));`, ` const cx = Math.round(rect.width / 2), cy = Math.round(rect.height / 2), half = Math.round(travel / 2);`, ` const sx = direction === 'left' ? cx + half : direction === 'right' ? cx - half : cx;`, ` const sy = direction === 'up' ? cy + half : direction === 'down' ? cy - half : cy;`, ` const ex = direction === 'left' ? cx - half : direction === 'right' ? cx + half : cx;`, ` const ey = direction === 'up' ? cy - half : direction === 'down' ? cy + half : cy;`, ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [`, ` { type: 'pointerMove', duration: 0, x: sx, y: sy }, { type: 'pointerDown', button: 0 }, { type: 'pointerMove', duration: 300, x: ex, y: ey }, { type: 'pointerUp', button: 0 },`, ` ] }]);`, `}`, ``);
1698
1830
  const setupLines = [
1699
- `// Standalone WebdriverIO script \u2014 runnable as-is with \`node\`/\`tsx\`.`,
1700
- `// Prerequisites:`,
1701
- `// 1. npm i webdriverio`,
1702
- `// 2. An Appium server reachable on http://localhost:4723`,
1703
- `// 3. A booted device/simulator matching the capabilities below`,
1704
- `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1705
- `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1706
- `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1831
+ `// ${toInlineCommentText(testName)} \u2014 ${steps.length} step(s), exported from validate.qa.`,
1832
+ `// Runnable standalone: \`npm i webdriverio\`, Appium on :4723, booted device. The`,
1833
+ `// canonical execution source is the steps JSON; this script mirrors it 1:1.`,
1707
1834
  `import { remote } from 'webdriverio';`,
1708
1835
  ``,
1709
- `// Resolve the first selector that exists, mirroring the runner's locator`,
1710
- `// fallback chain so a stale primary the runner would recover from doesn't fail`,
1711
- `// the exported script.`,
1712
- `async function findFirst(driver: WebdriverIO.Browser, selectors: string[]) {`,
1713
- ` let __lastError: unknown;`,
1714
- ` for (const __sel of selectors) {`,
1715
- ` try {`,
1716
- ` const __el = await driver.$(__sel);`,
1717
- ` if (await __el.isExisting()) return __el;`,
1718
- ` } catch (err) { __lastError = err; }`,
1719
- ` }`,
1720
- ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1721
- `}`,
1722
- ``,
1723
- `async function __tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`,
1724
- ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`,
1725
- `}`,
1726
- ``,
1727
- `async function __typeIntoFocused(driver: WebdriverIO.Browser, text: string) {`,
1728
- ` try {`,
1729
- ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1730
- ` } catch {`,
1731
- ` await driver.keys(text);`,
1732
- ` }`,
1733
- `}`,
1734
- ``,
1735
- `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1736
- ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1737
- `}`,
1738
- ``,
1739
- `async function __textCandidates(el: WebdriverIO.Element) {`,
1740
- ` const __values: string[] = [];`,
1741
- ` const __push = (__value: unknown) => {`,
1742
- ` if (typeof __value !== 'string') return;`,
1743
- ` const __trimmed = __value.trim();`,
1744
- ` if (__trimmed.length === 0 || __values.includes(__trimmed)) return;`,
1745
- ` __values.push(__trimmed);`,
1746
- ` };`,
1747
- ` __push(await el.getText().catch(() => ''));`,
1748
- ` for (const __attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) {`,
1749
- ` __push(await el.getAttribute(__attr).catch(() => ''));`,
1750
- ` }`,
1751
- ` return __values;`,
1752
- `}`,
1753
- ...hasSwipe ? [
1754
- ``,
1755
- `// Read the device window with a few retries (matches the runtime executor).`,
1756
- `async function __getWindowRect(driver: WebdriverIO.Browser) {`,
1757
- ` for (let __i = 0; __i < 3; __i++) {`,
1758
- ` try { const __r = await driver.getWindowRect(); if (__r && __r.width > 0 && __r.height > 0) return __r; } catch { /* retry */ }`,
1759
- ` await driver.pause(Math.pow(2, __i) * 100);`,
1760
- ` }`,
1761
- ` throw new Error('Could not determine device window size');`,
1762
- `}`
1763
- ] : [],
1764
- ``,
1836
+ ...helperLines,
1765
1837
  `async function main() {`,
1766
1838
  ` const driver = await remote({`,
1767
1839
  ` path: '/',`,
@@ -1782,108 +1854,45 @@ function buildAppiumCode(testName, target, steps) {
1782
1854
  ` // the same entry screen as during recording.`,
1783
1855
  platform3 === "iOS" ? ` await driver.execute('mobile: deepLink', { url: ${toJsStringLiteral(deeplink)}, bundleId: ${toJsStringLiteral(appId)} });` : ` await driver.execute('mobile: deepLink', { url: ${toJsStringLiteral(deeplink)}, package: ${toJsStringLiteral(appId)} });`,
1784
1856
  ``
1785
- ] : [],
1786
- ` // Canonical execution source: steps JSON`
1857
+ ] : []
1787
1858
  ];
1788
1859
  const stepLines = steps.flatMap((step) => {
1789
- const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1860
+ const lines = [` // ${step.order + 1}. ${toInlineCommentText(step.description)}`];
1790
1861
  const candidates = selectorCandidatesForStep(step);
1791
- const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1862
+ const sel = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1792
1863
  const hasSelector = candidates.length > 0;
1793
- const bounds = step.metadata?.bounds;
1864
+ const bounds = boundsOf(step);
1865
+ const at = bounds ? `, ${atLiteral(bounds)}` : "";
1794
1866
  if (step.action === "tap" && hasSelector) {
1795
- if (bounds) {
1796
- const x = Math.round(bounds.x + bounds.width / 2);
1797
- const y = Math.round(bounds.y + bounds.height / 2);
1798
- lines.push(` try {`);
1799
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).click();`);
1800
- lines.push(` } catch {`);
1801
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1802
- lines.push(` }`);
1803
- } else {
1804
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).click();`);
1805
- }
1867
+ lines.push(` await tap(driver, ${sel}${at});`);
1806
1868
  } else if (step.action === "tap" && bounds) {
1807
- const x = Math.round(bounds.x + bounds.width / 2);
1808
- const y = Math.round(bounds.y + bounds.height / 2);
1809
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1810
- } else if (step.action === "type" && hasSelector) {
1811
- if (bounds) {
1812
- const x = Math.round(bounds.x + bounds.width / 2);
1813
- const y = Math.round(bounds.y + bounds.height / 2);
1814
- lines.push(` try {`);
1815
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1816
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1817
- lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1818
- lines.push(` } catch {`);
1819
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1820
- lines.push(` }`);
1821
- } else {
1822
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1823
- }
1824
- } else if (step.action === "type" && bounds) {
1825
- const x = Math.round(bounds.x + bounds.width / 2);
1826
- const y = Math.round(bounds.y + bounds.height / 2);
1827
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1828
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1829
- lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1830
- } else if (step.action === "clear" && hasSelector) {
1831
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1832
- } else if (step.action === "clear" && bounds) {
1833
- const x = Math.round(bounds.x + bounds.width / 2);
1834
- const y = Math.round(bounds.y + bounds.height / 2);
1835
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1836
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1837
- lines.push(` await __clearFocused(driver);`);
1869
+ const c = centerOf(bounds);
1870
+ lines.push(` await tapAt(driver, ${c.x}, ${c.y});`);
1871
+ } else if (step.action === "type" && (hasSelector || bounds)) {
1872
+ lines.push(` await typeInto(driver, ${sel}, ${toJsStringLiteral(step.value ?? "")}${at});`);
1873
+ } else if (step.action === "clear" && (hasSelector || bounds)) {
1874
+ lines.push(` await clearField(driver, ${sel}${at});`);
1838
1875
  } else if (step.action === "assertVisible" && hasSelector) {
1839
- lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1840
- lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
1841
- lines.push(` }`);
1876
+ lines.push(` await expectVisible(driver, ${sel});`);
1842
1877
  } else if (step.action === "assertText" && hasSelector) {
1843
- lines.push(` {`);
1844
- lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1845
- lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1846
- lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1847
- lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1848
- lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
1849
- lines.push(` }`);
1850
- lines.push(` }`);
1878
+ lines.push(` await expectText(driver, ${sel}, ${toJsStringLiteral(step.assertion ?? "")});`);
1851
1879
  } else if (step.action === "back") {
1852
1880
  lines.push(` await driver.back();`);
1853
1881
  } else if (step.action === "home") {
1854
- if (platform3 === "iOS") {
1855
- lines.push(` await driver.execute('mobile: pressButton', { name: 'home' });`);
1856
- } else {
1857
- lines.push(` await driver.pressKeyCode(3);`);
1858
- }
1882
+ lines.push(platform3 === "iOS" ? ` await driver.execute('mobile: pressButton', { name: 'home' });` : ` await driver.pressKeyCode(3); // KEYCODE_HOME \u2014 same endpoint as record/replay`);
1859
1883
  } else if (step.action === "swipe" || step.action === "scroll") {
1860
1884
  const rawDir = nonEmpty(step.metadata?.direction);
1861
1885
  const swipeDir = rawDir && ["down", "left", "right"].includes(rawDir) ? rawDir : "up";
1862
1886
  const rawDist = step.metadata?.distance;
1863
1887
  const swipeDist = typeof rawDist === "number" && Number.isFinite(rawDist) && rawDist > 0 ? Math.min(rawDist, MOBILE_MAX_SWIPE_DISTANCE) : MOBILE_DEFAULT_SWIPE_DISTANCE;
1864
- const vertical = swipeDir === "up" || swipeDir === "down";
1865
- const startXExpr = swipeDir === "left" ? "__cx + __half" : swipeDir === "right" ? "__cx - __half" : "__cx";
1866
- const startYExpr = swipeDir === "up" ? "__cy + __half" : swipeDir === "down" ? "__cy - __half" : "__cy";
1867
- const endXExpr = swipeDir === "left" ? "__cx - __half" : swipeDir === "right" ? "__cx + __half" : "__cx";
1868
- const endYExpr = swipeDir === "up" ? "__cy - __half" : swipeDir === "down" ? "__cy + __half" : "__cy";
1869
- lines.push(` {`);
1870
- lines.push(` const __rect = await __getWindowRect(driver);`);
1871
- lines.push(` const __w = __rect.width, __h = __rect.height;`);
1872
- lines.push(` const __axis = ${vertical ? "__h" : "__w"};`);
1873
- lines.push(` const __travel = Math.min(${swipeDist}, Math.max(1, Math.round(__axis * 0.8)));`);
1874
- lines.push(` const __cx = Math.round(__w / 2), __cy = Math.round(__h / 2), __half = Math.round(__travel / 2);`);
1875
- lines.push(` const __startX = ${startXExpr}, __startY = ${startYExpr};`);
1876
- lines.push(` const __endX = ${endXExpr}, __endY = ${endYExpr};`);
1877
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: __startX, y: __startY }, { type: 'pointerDown', button: 0 }, { type: 'pointerMove', duration: 300, x: __endX, y: __endY }, { type: 'pointerUp', button: 0 }] }]);`);
1878
- lines.push(` }`);
1888
+ lines.push(` await swipe(driver, '${swipeDir}', ${swipeDist});`);
1879
1889
  } else if (step.action === "launchApp") {
1880
- lines.push(` // App is launched by capabilities`);
1890
+ lines.push(` // App is launched by the capabilities above`);
1881
1891
  } else if (step.action === "waitForElement" && hasSelector) {
1882
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).waitForExist({ timeout: 10000 });`);
1892
+ lines.push(` await (await find(driver, ${sel})).waitForExist({ timeout: 10000 });`);
1883
1893
  } else {
1884
1894
  lines.push(` // Manual action required for: ${step.action}`);
1885
1895
  }
1886
- lines.push("");
1887
1896
  return lines;
1888
1897
  });
1889
1898
  return [
@@ -67193,6 +67202,10 @@ ${statePacket}` },
67193
67202
  return null;
67194
67203
  const elements = snapshot.screen.elements;
67195
67204
  const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
67205
+ stableAnchors.sort((a, b) => {
67206
+ const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
67207
+ return labelled2(a) - labelled2(b);
67208
+ });
67196
67209
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67197
67210
  if (contentfulNonButton)
67198
67211
  return contentfulNonButton;
@@ -329512,7 +329525,7 @@ var require_package4 = __commonJS({
329512
329525
  "package.json"(exports2, module2) {
329513
329526
  module2.exports = {
329514
329527
  name: "@validate.qa/runner",
329515
- version: "1.0.26",
329528
+ version: "1.0.27",
329516
329529
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329517
329530
  bin: {
329518
329531
  "validate-runner": "dist/cli.js"
package/dist/cli.mjs CHANGED
@@ -1034,9 +1034,39 @@ var require_mobile_source_parser = __commonJS({
1034
1034
  function isEditableElement(el) {
1035
1035
  return EDITABLE_CLASS_RE.test(el.role.trim());
1036
1036
  }
1037
+ function xpathAttrLiteral(value) {
1038
+ if (!value.includes("'"))
1039
+ return `'${value}'`;
1040
+ if (!value.includes('"'))
1041
+ return `"${value}"`;
1042
+ return `concat('${value.split("'").join(`', "'", '`)}')`;
1043
+ }
1044
+ function semanticXPath(el, platform3) {
1045
+ const cls = nonBlank(el.role) ?? "*";
1046
+ const a11y = nonBlank(el.accessibilityId);
1047
+ if (a11y) {
1048
+ return platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]`;
1049
+ }
1050
+ const rid = nonBlank(el.resourceId);
1051
+ if (platform3 === "ANDROID" && rid)
1052
+ return `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]`;
1053
+ const text = nonBlank(el.text);
1054
+ if (text && !isEditableElement(el)) {
1055
+ return platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]`;
1056
+ }
1057
+ return void 0;
1058
+ }
1059
+ function nonBlank(value) {
1060
+ if (typeof value !== "string")
1061
+ return void 0;
1062
+ const trimmed = value.trim();
1063
+ return trimmed.length > 0 ? trimmed : void 0;
1064
+ }
1037
1065
  function buildLocatorBundle(el, platform3) {
1038
1066
  const bundle = {
1039
- xpath: el.xpath,
1067
+ // Attribute-anchored xpath when the element has identity; the absolute
1068
+ // indexed path only for identity-less elements (nothing else can find them).
1069
+ xpath: semanticXPath(el, platform3) ?? el.xpath,
1040
1070
  className: el.role
1041
1071
  };
1042
1072
  if (el.accessibilityId)
@@ -1549,7 +1579,7 @@ function buildStepTarget(metadata, selector) {
1549
1579
  locatorBundle.xpath,
1550
1580
  selector
1551
1581
  ];
1552
- const fallbacks = fallbackCandidates.map(nonEmpty).filter((value, index, list) => typeof value === "string" && value !== primary && list.indexOf(value) === index);
1582
+ const fallbacks = fallbackCandidates.map(nonEmpty).filter((value, index, list) => typeof value === "string" && value !== primary && list.indexOf(value) === index).filter((value) => !value.startsWith("/hierarchy") && !(value.startsWith("/") && value.length > 120));
1553
1583
  return {
1554
1584
  primary,
1555
1585
  fallbacks,
@@ -1680,8 +1710,6 @@ function selectorCandidatesForStep(step) {
1680
1710
  candidates.push(`id=${bundle.resourceId}`);
1681
1711
  if (nonEmpty(bundle.xpath))
1682
1712
  candidates.push(bundle.xpath);
1683
- if (nonEmpty(bundle.className))
1684
- candidates.push(`class=${bundle.className}`);
1685
1713
  }
1686
1714
  if (nonEmpty(target.primary))
1687
1715
  candidates.push(selectorForValue(target.primary));
@@ -1689,7 +1717,7 @@ function selectorCandidatesForStep(step) {
1689
1717
  if (nonEmpty(fallback))
1690
1718
  candidates.push(selectorForValue(fallback));
1691
1719
  }
1692
- return candidates.filter((value, index, list) => list.indexOf(value) === index);
1720
+ return candidates.filter((value) => !value.startsWith("/hierarchy") && !(value.startsWith("/") && !value.startsWith("//") && value.length > 120)).filter((value, index, list) => list.indexOf(value) === index).slice(0, 4);
1693
1721
  }
1694
1722
  function buildAppiumCode(testName, target, steps) {
1695
1723
  const appId = target?.appId ?? "com.example.app";
@@ -1699,74 +1727,118 @@ function buildAppiumCode(testName, target, steps) {
1699
1727
  const platformVersion = target?.platformVersion;
1700
1728
  const launchMode = target?.launchMode;
1701
1729
  const deeplink = target?.deeplink;
1702
- const hasSwipe = steps.some((s) => s.action === "swipe" || s.action === "scroll");
1730
+ const boundsOf = (step) => step.metadata?.bounds;
1731
+ const centerOf = (b) => ({ x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) });
1732
+ const atLiteral = (b) => {
1733
+ const c = centerOf(b);
1734
+ return `{ x: ${c.x}, y: ${c.y} }`;
1735
+ };
1736
+ const used = {
1737
+ find: false,
1738
+ tap: false,
1739
+ tapAt: false,
1740
+ typeInto: false,
1741
+ clearField: false,
1742
+ expectVisible: false,
1743
+ expectText: false,
1744
+ swipe: false,
1745
+ wait: false
1746
+ };
1747
+ for (const step of steps) {
1748
+ const hasSelector = selectorCandidatesForStep(step).length > 0;
1749
+ const bounds = boundsOf(step);
1750
+ switch (step.action) {
1751
+ case "tap":
1752
+ if (hasSelector) {
1753
+ used.tap = true;
1754
+ used.find = true;
1755
+ if (bounds)
1756
+ used.tapAt = true;
1757
+ } else if (bounds)
1758
+ used.tapAt = true;
1759
+ break;
1760
+ case "type":
1761
+ used.typeInto = true;
1762
+ if (hasSelector)
1763
+ used.find = true;
1764
+ if (bounds)
1765
+ used.tapAt = true;
1766
+ break;
1767
+ case "clear":
1768
+ used.clearField = true;
1769
+ if (hasSelector)
1770
+ used.find = true;
1771
+ if (bounds)
1772
+ used.tapAt = true;
1773
+ break;
1774
+ case "assertVisible":
1775
+ if (hasSelector) {
1776
+ used.expectVisible = true;
1777
+ used.find = true;
1778
+ }
1779
+ break;
1780
+ case "assertText":
1781
+ if (hasSelector) {
1782
+ used.expectText = true;
1783
+ used.find = true;
1784
+ }
1785
+ break;
1786
+ case "swipe":
1787
+ case "scroll":
1788
+ used.swipe = true;
1789
+ break;
1790
+ case "waitForElement":
1791
+ if (hasSelector) {
1792
+ used.wait = true;
1793
+ used.find = true;
1794
+ }
1795
+ break;
1796
+ default:
1797
+ break;
1798
+ }
1799
+ }
1800
+ const helperLines = [];
1801
+ if (used.find)
1802
+ helperLines.push(`// First selector that resolves wins \u2014 the runner's exact fallback chain.`, `async function find(driver: WebdriverIO.Browser, selectors: string[]) {`, ` for (const sel of selectors) {`, ` try { const el = await driver.$(sel); if (await el.isExisting()) return el; } catch { /* try next */ }`, ` }`, ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors));`, `}`, ``);
1803
+ if (used.tapAt)
1804
+ helperLines.push(`async function tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`, ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [`, ` { type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 },`, ` ] }]);`, `}`, ``);
1805
+ if (used.tap)
1806
+ helperLines.push(`// Locator-first tap; falls back to the recorded coordinates like the runner.`, `async function tap(driver: WebdriverIO.Browser, selectors: string[], at?: { x: number; y: number }) {`, used.tapAt ? ` try { await (await find(driver, selectors)).click(); } catch (err) { if (!at) throw err; await tapAt(driver, at.x, at.y); }` : ` await (await find(driver, selectors)).click();`, `}`, ``);
1807
+ if (used.typeInto)
1808
+ helperLines.push(`// addValue (append) matches replay exactly; with recorded coordinates it`, `// taps to focus first (placeholder locators go stale once the keyboard opens).`, `async function typeInto(driver: WebdriverIO.Browser, selectors: string[], text: string, at?: { x: number; y: number }) {`, ...used.tapAt ? [
1809
+ ` if (at) {`,
1810
+ ` try {`,
1811
+ ` await tapAt(driver, at.x, at.y);`,
1812
+ ` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`,
1813
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1814
+ ` return;`,
1815
+ ` } catch { /* fall through to locator path */ }`,
1816
+ ` }`
1817
+ ] : [], ` await (await find(driver, selectors)).addValue(text);`, `}`, ``);
1818
+ if (used.clearField)
1819
+ helperLines.push(`async function clearField(driver: WebdriverIO.Browser, selectors: string[], at?: { x: number; y: number }) {`, ...used.tapAt ? [
1820
+ ` if (at) {`,
1821
+ ` try {`,
1822
+ ` await tapAt(driver, at.x, at.y);`,
1823
+ ` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`,
1824
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1825
+ ` return;`,
1826
+ ` } catch { /* fall through to locator path */ }`,
1827
+ ` }`
1828
+ ] : [], ` await (await find(driver, selectors)).clearValue();`, `}`, ``);
1829
+ if (used.expectVisible)
1830
+ helperLines.push(`async function expectVisible(driver: WebdriverIO.Browser, selectors: string[]) {`, ` if (!(await (await find(driver, selectors)).isDisplayed())) {`, ` throw new Error('Assertion failed: element not displayed: ' + JSON.stringify(selectors));`, ` }`, `}`, ``);
1831
+ if (used.expectText)
1832
+ helperLines.push(`// Contains-match over text-like attributes \u2014 mirrors the runtime executor.`, `async function expectText(driver: WebdriverIO.Browser, selectors: string[], expected: string) {`, ` if (expected.length === 0) throw new Error('assertText step is missing expected text');`, ` const el = await find(driver, selectors);`, ` const values: string[] = [];`, ` const push = (v: unknown) => { if (typeof v === 'string' && v.trim() && !values.includes(v.trim())) values.push(v.trim()); };`, ` push(await el.getText().catch(() => ''));`, ` for (const attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) push(await el.getAttribute(attr).catch(() => ''));`, ` if (!values.some((v) => v.includes(expected))) {`, ` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(expected) + ' but got ' + JSON.stringify(values));`, ` }`, `}`, ``);
1833
+ if (used.swipe)
1834
+ helperLines.push(`// Centred W3C swipe with the executor's geometry (full distance, clamped to 80% of the axis).`, `async function swipe(driver: WebdriverIO.Browser, direction: 'up' | 'down' | 'left' | 'right', distance: number) {`, ` let rect: { width: number; height: number } | undefined;`, ` for (let i = 0; i < 3 && !rect; i++) {`, ` try { const r = await driver.getWindowRect(); if (r && r.width > 0 && r.height > 0) rect = r; } catch { await driver.pause(Math.pow(2, i) * 100); }`, ` }`, ` if (!rect) throw new Error('Could not determine device window size');`, ` const vertical = direction === 'up' || direction === 'down';`, ` const travel = Math.min(distance, Math.max(1, Math.round((vertical ? rect.height : rect.width) * 0.8)));`, ` const cx = Math.round(rect.width / 2), cy = Math.round(rect.height / 2), half = Math.round(travel / 2);`, ` const sx = direction === 'left' ? cx + half : direction === 'right' ? cx - half : cx;`, ` const sy = direction === 'up' ? cy + half : direction === 'down' ? cy - half : cy;`, ` const ex = direction === 'left' ? cx - half : direction === 'right' ? cx + half : cx;`, ` const ey = direction === 'up' ? cy - half : direction === 'down' ? cy + half : cy;`, ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [`, ` { type: 'pointerMove', duration: 0, x: sx, y: sy }, { type: 'pointerDown', button: 0 }, { type: 'pointerMove', duration: 300, x: ex, y: ey }, { type: 'pointerUp', button: 0 },`, ` ] }]);`, `}`, ``);
1703
1835
  const setupLines = [
1704
- `// Standalone WebdriverIO script \u2014 runnable as-is with \`node\`/\`tsx\`.`,
1705
- `// Prerequisites:`,
1706
- `// 1. npm i webdriverio`,
1707
- `// 2. An Appium server reachable on http://localhost:4723`,
1708
- `// 3. A booted device/simulator matching the capabilities below`,
1709
- `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1710
- `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1711
- `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1836
+ `// ${toInlineCommentText(testName)} \u2014 ${steps.length} step(s), exported from validate.qa.`,
1837
+ `// Runnable standalone: \`npm i webdriverio\`, Appium on :4723, booted device. The`,
1838
+ `// canonical execution source is the steps JSON; this script mirrors it 1:1.`,
1712
1839
  `import { remote } from 'webdriverio';`,
1713
1840
  ``,
1714
- `// Resolve the first selector that exists, mirroring the runner's locator`,
1715
- `// fallback chain so a stale primary the runner would recover from doesn't fail`,
1716
- `// the exported script.`,
1717
- `async function findFirst(driver: WebdriverIO.Browser, selectors: string[]) {`,
1718
- ` let __lastError: unknown;`,
1719
- ` for (const __sel of selectors) {`,
1720
- ` try {`,
1721
- ` const __el = await driver.$(__sel);`,
1722
- ` if (await __el.isExisting()) return __el;`,
1723
- ` } catch (err) { __lastError = err; }`,
1724
- ` }`,
1725
- ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1726
- `}`,
1727
- ``,
1728
- `async function __tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`,
1729
- ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`,
1730
- `}`,
1731
- ``,
1732
- `async function __typeIntoFocused(driver: WebdriverIO.Browser, text: string) {`,
1733
- ` try {`,
1734
- ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1735
- ` } catch {`,
1736
- ` await driver.keys(text);`,
1737
- ` }`,
1738
- `}`,
1739
- ``,
1740
- `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1741
- ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1742
- `}`,
1743
- ``,
1744
- `async function __textCandidates(el: WebdriverIO.Element) {`,
1745
- ` const __values: string[] = [];`,
1746
- ` const __push = (__value: unknown) => {`,
1747
- ` if (typeof __value !== 'string') return;`,
1748
- ` const __trimmed = __value.trim();`,
1749
- ` if (__trimmed.length === 0 || __values.includes(__trimmed)) return;`,
1750
- ` __values.push(__trimmed);`,
1751
- ` };`,
1752
- ` __push(await el.getText().catch(() => ''));`,
1753
- ` for (const __attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) {`,
1754
- ` __push(await el.getAttribute(__attr).catch(() => ''));`,
1755
- ` }`,
1756
- ` return __values;`,
1757
- `}`,
1758
- ...hasSwipe ? [
1759
- ``,
1760
- `// Read the device window with a few retries (matches the runtime executor).`,
1761
- `async function __getWindowRect(driver: WebdriverIO.Browser) {`,
1762
- ` for (let __i = 0; __i < 3; __i++) {`,
1763
- ` try { const __r = await driver.getWindowRect(); if (__r && __r.width > 0 && __r.height > 0) return __r; } catch { /* retry */ }`,
1764
- ` await driver.pause(Math.pow(2, __i) * 100);`,
1765
- ` }`,
1766
- ` throw new Error('Could not determine device window size');`,
1767
- `}`
1768
- ] : [],
1769
- ``,
1841
+ ...helperLines,
1770
1842
  `async function main() {`,
1771
1843
  ` const driver = await remote({`,
1772
1844
  ` path: '/',`,
@@ -1787,108 +1859,45 @@ function buildAppiumCode(testName, target, steps) {
1787
1859
  ` // the same entry screen as during recording.`,
1788
1860
  platform3 === "iOS" ? ` await driver.execute('mobile: deepLink', { url: ${toJsStringLiteral(deeplink)}, bundleId: ${toJsStringLiteral(appId)} });` : ` await driver.execute('mobile: deepLink', { url: ${toJsStringLiteral(deeplink)}, package: ${toJsStringLiteral(appId)} });`,
1789
1861
  ``
1790
- ] : [],
1791
- ` // Canonical execution source: steps JSON`
1862
+ ] : []
1792
1863
  ];
1793
1864
  const stepLines = steps.flatMap((step) => {
1794
- const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1865
+ const lines = [` // ${step.order + 1}. ${toInlineCommentText(step.description)}`];
1795
1866
  const candidates = selectorCandidatesForStep(step);
1796
- const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1867
+ const sel = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1797
1868
  const hasSelector = candidates.length > 0;
1798
- const bounds = step.metadata?.bounds;
1869
+ const bounds = boundsOf(step);
1870
+ const at = bounds ? `, ${atLiteral(bounds)}` : "";
1799
1871
  if (step.action === "tap" && hasSelector) {
1800
- if (bounds) {
1801
- const x = Math.round(bounds.x + bounds.width / 2);
1802
- const y = Math.round(bounds.y + bounds.height / 2);
1803
- lines.push(` try {`);
1804
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).click();`);
1805
- lines.push(` } catch {`);
1806
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1807
- lines.push(` }`);
1808
- } else {
1809
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).click();`);
1810
- }
1872
+ lines.push(` await tap(driver, ${sel}${at});`);
1811
1873
  } else if (step.action === "tap" && bounds) {
1812
- const x = Math.round(bounds.x + bounds.width / 2);
1813
- const y = Math.round(bounds.y + bounds.height / 2);
1814
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1815
- } else if (step.action === "type" && hasSelector) {
1816
- if (bounds) {
1817
- const x = Math.round(bounds.x + bounds.width / 2);
1818
- const y = Math.round(bounds.y + bounds.height / 2);
1819
- lines.push(` try {`);
1820
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1821
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1822
- lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1823
- lines.push(` } catch {`);
1824
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1825
- lines.push(` }`);
1826
- } else {
1827
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1828
- }
1829
- } else if (step.action === "type" && bounds) {
1830
- const x = Math.round(bounds.x + bounds.width / 2);
1831
- const y = Math.round(bounds.y + bounds.height / 2);
1832
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1833
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1834
- lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1835
- } else if (step.action === "clear" && hasSelector) {
1836
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1837
- } else if (step.action === "clear" && bounds) {
1838
- const x = Math.round(bounds.x + bounds.width / 2);
1839
- const y = Math.round(bounds.y + bounds.height / 2);
1840
- lines.push(` await __tapAt(driver, ${x}, ${y});`);
1841
- lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1842
- lines.push(` await __clearFocused(driver);`);
1874
+ const c = centerOf(bounds);
1875
+ lines.push(` await tapAt(driver, ${c.x}, ${c.y});`);
1876
+ } else if (step.action === "type" && (hasSelector || bounds)) {
1877
+ lines.push(` await typeInto(driver, ${sel}, ${toJsStringLiteral(step.value ?? "")}${at});`);
1878
+ } else if (step.action === "clear" && (hasSelector || bounds)) {
1879
+ lines.push(` await clearField(driver, ${sel}${at});`);
1843
1880
  } else if (step.action === "assertVisible" && hasSelector) {
1844
- lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1845
- lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
1846
- lines.push(` }`);
1881
+ lines.push(` await expectVisible(driver, ${sel});`);
1847
1882
  } else if (step.action === "assertText" && hasSelector) {
1848
- lines.push(` {`);
1849
- lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1850
- lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1851
- lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1852
- lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1853
- lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
1854
- lines.push(` }`);
1855
- lines.push(` }`);
1883
+ lines.push(` await expectText(driver, ${sel}, ${toJsStringLiteral(step.assertion ?? "")});`);
1856
1884
  } else if (step.action === "back") {
1857
1885
  lines.push(` await driver.back();`);
1858
1886
  } else if (step.action === "home") {
1859
- if (platform3 === "iOS") {
1860
- lines.push(` await driver.execute('mobile: pressButton', { name: 'home' });`);
1861
- } else {
1862
- lines.push(` await driver.pressKeyCode(3);`);
1863
- }
1887
+ lines.push(platform3 === "iOS" ? ` await driver.execute('mobile: pressButton', { name: 'home' });` : ` await driver.pressKeyCode(3); // KEYCODE_HOME \u2014 same endpoint as record/replay`);
1864
1888
  } else if (step.action === "swipe" || step.action === "scroll") {
1865
1889
  const rawDir = nonEmpty(step.metadata?.direction);
1866
1890
  const swipeDir = rawDir && ["down", "left", "right"].includes(rawDir) ? rawDir : "up";
1867
1891
  const rawDist = step.metadata?.distance;
1868
1892
  const swipeDist = typeof rawDist === "number" && Number.isFinite(rawDist) && rawDist > 0 ? Math.min(rawDist, MOBILE_MAX_SWIPE_DISTANCE) : MOBILE_DEFAULT_SWIPE_DISTANCE;
1869
- const vertical = swipeDir === "up" || swipeDir === "down";
1870
- const startXExpr = swipeDir === "left" ? "__cx + __half" : swipeDir === "right" ? "__cx - __half" : "__cx";
1871
- const startYExpr = swipeDir === "up" ? "__cy + __half" : swipeDir === "down" ? "__cy - __half" : "__cy";
1872
- const endXExpr = swipeDir === "left" ? "__cx - __half" : swipeDir === "right" ? "__cx + __half" : "__cx";
1873
- const endYExpr = swipeDir === "up" ? "__cy - __half" : swipeDir === "down" ? "__cy + __half" : "__cy";
1874
- lines.push(` {`);
1875
- lines.push(` const __rect = await __getWindowRect(driver);`);
1876
- lines.push(` const __w = __rect.width, __h = __rect.height;`);
1877
- lines.push(` const __axis = ${vertical ? "__h" : "__w"};`);
1878
- lines.push(` const __travel = Math.min(${swipeDist}, Math.max(1, Math.round(__axis * 0.8)));`);
1879
- lines.push(` const __cx = Math.round(__w / 2), __cy = Math.round(__h / 2), __half = Math.round(__travel / 2);`);
1880
- lines.push(` const __startX = ${startXExpr}, __startY = ${startYExpr};`);
1881
- lines.push(` const __endX = ${endXExpr}, __endY = ${endYExpr};`);
1882
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: __startX, y: __startY }, { type: 'pointerDown', button: 0 }, { type: 'pointerMove', duration: 300, x: __endX, y: __endY }, { type: 'pointerUp', button: 0 }] }]);`);
1883
- lines.push(` }`);
1893
+ lines.push(` await swipe(driver, '${swipeDir}', ${swipeDist});`);
1884
1894
  } else if (step.action === "launchApp") {
1885
- lines.push(` // App is launched by capabilities`);
1895
+ lines.push(` // App is launched by the capabilities above`);
1886
1896
  } else if (step.action === "waitForElement" && hasSelector) {
1887
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).waitForExist({ timeout: 10000 });`);
1897
+ lines.push(` await (await find(driver, ${sel})).waitForExist({ timeout: 10000 });`);
1888
1898
  } else {
1889
1899
  lines.push(` // Manual action required for: ${step.action}`);
1890
1900
  }
1891
- lines.push("");
1892
1901
  return lines;
1893
1902
  });
1894
1903
  return [
@@ -67198,6 +67207,10 @@ ${statePacket}` },
67198
67207
  return null;
67199
67208
  const elements = snapshot.screen.elements;
67200
67209
  const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
67210
+ stableAnchors.sort((a, b) => {
67211
+ const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
67212
+ return labelled2(a) - labelled2(b);
67213
+ });
67201
67214
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67202
67215
  if (contentfulNonButton)
67203
67216
  return contentfulNonButton;
@@ -329517,7 +329530,7 @@ var require_package4 = __commonJS({
329517
329530
  "package.json"(exports, module) {
329518
329531
  module.exports = {
329519
329532
  name: "@validate.qa/runner",
329520
- version: "1.0.26",
329533
+ version: "1.0.27",
329521
329534
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329522
329535
  bin: {
329523
329536
  "validate-runner": "dist/cli.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"