@validate.qa/runner 1.0.26 → 1.0.28
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 +166 -152
- package/dist/cli.mjs +166 -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 [
|
|
@@ -65779,6 +65788,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65779
65788
|
8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
|
|
65780
65789
|
9. **No duplicates** - check existing test names before adding
|
|
65781
65790
|
10. **All tests start from a hermetic app launch** - every generated test relaunches the app (terminate + activate, a clean cold start) and uses the app's own in-app navigation (tapping tabs / links / buttons) to reach the target screen. Steps must reflect this: say "Relaunch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
|
|
65791
|
+
12. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
|
|
65782
65792
|
11. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" is NOT a happy path - that is a UI interaction test. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
|
|
65783
65793
|
- The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
|
|
65784
65794
|
- A wait step for the operation to complete. Phrase it by SIGNAL, not wall-clock ("Wait for the result to appear - up to 60 seconds"). Prefer signals the app actually uses: spinner disappears, status badge flips to a success state, a new row appears, an item count increments, a success toast/snackbar shows, the screen transitions to a result screen.
|
|
@@ -67193,6 +67203,10 @@ ${statePacket}` },
|
|
|
67193
67203
|
return null;
|
|
67194
67204
|
const elements = snapshot.screen.elements;
|
|
67195
67205
|
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
67206
|
+
stableAnchors.sort((a, b) => {
|
|
67207
|
+
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67208
|
+
return labelled2(a) - labelled2(b);
|
|
67209
|
+
});
|
|
67196
67210
|
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67197
67211
|
if (contentfulNonButton)
|
|
67198
67212
|
return contentfulNonButton;
|
|
@@ -329512,7 +329526,7 @@ var require_package4 = __commonJS({
|
|
|
329512
329526
|
"package.json"(exports2, module2) {
|
|
329513
329527
|
module2.exports = {
|
|
329514
329528
|
name: "@validate.qa/runner",
|
|
329515
|
-
version: "1.0.
|
|
329529
|
+
version: "1.0.28",
|
|
329516
329530
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329517
329531
|
bin: {
|
|
329518
329532
|
"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 [
|
|
@@ -65784,6 +65793,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65784
65793
|
8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
|
|
65785
65794
|
9. **No duplicates** - check existing test names before adding
|
|
65786
65795
|
10. **All tests start from a hermetic app launch** - every generated test relaunches the app (terminate + activate, a clean cold start) and uses the app's own in-app navigation (tapping tabs / links / buttons) to reach the target screen. Steps must reflect this: say "Relaunch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
|
|
65796
|
+
12. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
|
|
65787
65797
|
11. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" is NOT a happy path - that is a UI interaction test. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
|
|
65788
65798
|
- The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
|
|
65789
65799
|
- A wait step for the operation to complete. Phrase it by SIGNAL, not wall-clock ("Wait for the result to appear - up to 60 seconds"). Prefer signals the app actually uses: spinner disappears, status badge flips to a success state, a new row appears, an item count increments, a success toast/snackbar shows, the screen transitions to a result screen.
|
|
@@ -67198,6 +67208,10 @@ ${statePacket}` },
|
|
|
67198
67208
|
return null;
|
|
67199
67209
|
const elements = snapshot.screen.elements;
|
|
67200
67210
|
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
67211
|
+
stableAnchors.sort((a, b) => {
|
|
67212
|
+
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67213
|
+
return labelled2(a) - labelled2(b);
|
|
67214
|
+
});
|
|
67201
67215
|
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67202
67216
|
if (contentfulNonButton)
|
|
67203
67217
|
return contentfulNonButton;
|
|
@@ -329517,7 +329531,7 @@ var require_package4 = __commonJS({
|
|
|
329517
329531
|
"package.json"(exports, module) {
|
|
329518
329532
|
module.exports = {
|
|
329519
329533
|
name: "@validate.qa/runner",
|
|
329520
|
-
version: "1.0.
|
|
329534
|
+
version: "1.0.28",
|
|
329521
329535
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329522
329536
|
bin: {
|
|
329523
329537
|
"validate-runner": "dist/cli.js"
|
package/package.json
CHANGED