@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.
- package/dist/cli.js +168 -152
- package/dist/cli.mjs +168 -152
- 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
|
|
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
|
|
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
|
-
`//
|
|
1700
|
-
`//
|
|
1701
|
-
`//
|
|
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
|
-
|
|
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 = [` //
|
|
1860
|
+
const lines = [` // ${step.order + 1}. ${toInlineCommentText(step.description)}`];
|
|
1790
1861
|
const candidates = selectorCandidatesForStep(step);
|
|
1791
|
-
const
|
|
1862
|
+
const sel = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
|
|
1792
1863
|
const hasSelector = candidates.length > 0;
|
|
1793
|
-
const bounds = step
|
|
1864
|
+
const bounds = boundsOf(step);
|
|
1865
|
+
const at = bounds ? `, ${atLiteral(bounds)}` : "";
|
|
1794
1866
|
if (step.action === "tap" && hasSelector) {
|
|
1795
|
-
|
|
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
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
`//
|
|
1705
|
-
`//
|
|
1706
|
-
`//
|
|
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
|
-
|
|
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 = [` //
|
|
1865
|
+
const lines = [` // ${step.order + 1}. ${toInlineCommentText(step.description)}`];
|
|
1795
1866
|
const candidates = selectorCandidatesForStep(step);
|
|
1796
|
-
const
|
|
1867
|
+
const sel = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
|
|
1797
1868
|
const hasSelector = candidates.length > 0;
|
|
1798
|
-
const bounds = step
|
|
1869
|
+
const bounds = boundsOf(step);
|
|
1870
|
+
const at = bounds ? `, ${atLiteral(bounds)}` : "";
|
|
1799
1871
|
if (step.action === "tap" && hasSelector) {
|
|
1800
|
-
|
|
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
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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