@validate.qa/runner 1.0.25 → 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 +168 -152
  2. package/dist/cli.mjs +168 -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 [
@@ -66590,6 +66599,7 @@ var require_mobile_generator = __commonJS({
66590
66599
  ## Rules
66591
66600
  - Re-snapshot before every assertion so the ref is fresh.
66592
66601
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
66602
+ - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs ("Add Your First \u2026", "Get Started", "No items yet"), onboarding banners, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
66593
66603
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
66594
66604
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
66595
66605
  - Do ONLY the actions this scenario needs. Do NOT re-open a menu you already opened, re-select an option that is already selected, toggle a setting back and forth, or repeat a step that already succeeded \u2014 EVERY action you take is baked into the generated test verbatim, so each extra action becomes a permanent junk step.
@@ -67192,6 +67202,10 @@ ${statePacket}` },
67192
67202
  return null;
67193
67203
  const elements = snapshot.screen.elements;
67194
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
+ });
67195
67209
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67196
67210
  if (contentfulNonButton)
67197
67211
  return contentfulNonButton;
@@ -67980,6 +67994,7 @@ Reason over steps + error + tree + registry and return working healed steps. Do
67980
67994
  1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
67981
67995
  2. STEP RESULTS: which steps passed before the failure.
67982
67996
  3. TREE: does the current screen match what the failed step expects?
67997
+ 3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs like "Add Your First \u2026", "No items yet", onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
67983
67998
  4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap "Get Started" before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
67984
67999
  ## Response \u2014 STRICT JSON, exactly one verdict:
67985
68000
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
@@ -68488,6 +68503,7 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68488
68503
  1. Compare the recorded first step's expected screen to the LIVE tree.
68489
68504
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
68490
68505
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68506
+ 3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA like "Add Your First \u2026") that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
68491
68507
  4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68492
68508
  ## Response \u2014 STRICT JSON, exactly one verdict:
68493
68509
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
@@ -329509,7 +329525,7 @@ var require_package4 = __commonJS({
329509
329525
  "package.json"(exports2, module2) {
329510
329526
  module2.exports = {
329511
329527
  name: "@validate.qa/runner",
329512
- version: "1.0.25",
329528
+ version: "1.0.27",
329513
329529
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329514
329530
  bin: {
329515
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 [
@@ -66595,6 +66604,7 @@ var require_mobile_generator = __commonJS({
66595
66604
  ## Rules
66596
66605
  - Re-snapshot before every assertion so the ref is fresh.
66597
66606
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
66607
+ - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs ("Add Your First \u2026", "Get Started", "No items yet"), onboarding banners, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
66598
66608
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
66599
66609
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
66600
66610
  - Do ONLY the actions this scenario needs. Do NOT re-open a menu you already opened, re-select an option that is already selected, toggle a setting back and forth, or repeat a step that already succeeded \u2014 EVERY action you take is baked into the generated test verbatim, so each extra action becomes a permanent junk step.
@@ -67197,6 +67207,10 @@ ${statePacket}` },
67197
67207
  return null;
67198
67208
  const elements = snapshot.screen.elements;
67199
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
+ });
67200
67214
  const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
67201
67215
  if (contentfulNonButton)
67202
67216
  return contentfulNonButton;
@@ -67985,6 +67999,7 @@ Reason over steps + error + tree + registry and return working healed steps. Do
67985
67999
  1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
67986
68000
  2. STEP RESULTS: which steps passed before the failure.
67987
68001
  3. TREE: does the current screen match what the failed step expects?
68002
+ 3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs like "Add Your First \u2026", "No items yet", onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
67988
68003
  4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap "Get Started" before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
67989
68004
  ## Response \u2014 STRICT JSON, exactly one verdict:
67990
68005
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
@@ -68493,6 +68508,7 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68493
68508
  1. Compare the recorded first step's expected screen to the LIVE tree.
68494
68509
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
68495
68510
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68511
+ 3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA like "Add Your First \u2026") that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
68496
68512
  4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68497
68513
  ## Response \u2014 STRICT JSON, exactly one verdict:
68498
68514
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
@@ -329514,7 +329530,7 @@ var require_package4 = __commonJS({
329514
329530
  "package.json"(exports, module) {
329515
329531
  module.exports = {
329516
329532
  name: "@validate.qa/runner",
329517
- version: "1.0.25",
329533
+ version: "1.0.27",
329518
329534
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329519
329535
  bin: {
329520
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.25",
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"