@validate.qa/runner 1.0.26 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +165 -152
- package/dist/cli.mjs +165 -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 [
|
|
@@ -67193,6 +67202,10 @@ ${statePacket}` },
|
|
|
67193
67202
|
return null;
|
|
67194
67203
|
const elements = snapshot.screen.elements;
|
|
67195
67204
|
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
67205
|
+
stableAnchors.sort((a, b) => {
|
|
67206
|
+
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67207
|
+
return labelled2(a) - labelled2(b);
|
|
67208
|
+
});
|
|
67196
67209
|
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67197
67210
|
if (contentfulNonButton)
|
|
67198
67211
|
return contentfulNonButton;
|
|
@@ -329512,7 +329525,7 @@ var require_package4 = __commonJS({
|
|
|
329512
329525
|
"package.json"(exports2, module2) {
|
|
329513
329526
|
module2.exports = {
|
|
329514
329527
|
name: "@validate.qa/runner",
|
|
329515
|
-
version: "1.0.
|
|
329528
|
+
version: "1.0.27",
|
|
329516
329529
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329517
329530
|
bin: {
|
|
329518
329531
|
"validate-runner": "dist/cli.js"
|
package/dist/cli.mjs
CHANGED
|
@@ -1034,9 +1034,39 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1034
1034
|
function isEditableElement(el) {
|
|
1035
1035
|
return EDITABLE_CLASS_RE.test(el.role.trim());
|
|
1036
1036
|
}
|
|
1037
|
+
function xpathAttrLiteral(value) {
|
|
1038
|
+
if (!value.includes("'"))
|
|
1039
|
+
return `'${value}'`;
|
|
1040
|
+
if (!value.includes('"'))
|
|
1041
|
+
return `"${value}"`;
|
|
1042
|
+
return `concat('${value.split("'").join(`', "'", '`)}')`;
|
|
1043
|
+
}
|
|
1044
|
+
function semanticXPath(el, platform3) {
|
|
1045
|
+
const cls = nonBlank(el.role) ?? "*";
|
|
1046
|
+
const a11y = nonBlank(el.accessibilityId);
|
|
1047
|
+
if (a11y) {
|
|
1048
|
+
return platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]`;
|
|
1049
|
+
}
|
|
1050
|
+
const rid = nonBlank(el.resourceId);
|
|
1051
|
+
if (platform3 === "ANDROID" && rid)
|
|
1052
|
+
return `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]`;
|
|
1053
|
+
const text = nonBlank(el.text);
|
|
1054
|
+
if (text && !isEditableElement(el)) {
|
|
1055
|
+
return platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]`;
|
|
1056
|
+
}
|
|
1057
|
+
return void 0;
|
|
1058
|
+
}
|
|
1059
|
+
function nonBlank(value) {
|
|
1060
|
+
if (typeof value !== "string")
|
|
1061
|
+
return void 0;
|
|
1062
|
+
const trimmed = value.trim();
|
|
1063
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1064
|
+
}
|
|
1037
1065
|
function buildLocatorBundle(el, platform3) {
|
|
1038
1066
|
const bundle = {
|
|
1039
|
-
xpath
|
|
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 [
|
|
@@ -67198,6 +67207,10 @@ ${statePacket}` },
|
|
|
67198
67207
|
return null;
|
|
67199
67208
|
const elements = snapshot.screen.elements;
|
|
67200
67209
|
const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
|
|
67210
|
+
stableAnchors.sort((a, b) => {
|
|
67211
|
+
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67212
|
+
return labelled2(a) - labelled2(b);
|
|
67213
|
+
});
|
|
67201
67214
|
const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67202
67215
|
if (contentfulNonButton)
|
|
67203
67216
|
return contentfulNonButton;
|
|
@@ -329517,7 +329530,7 @@ var require_package4 = __commonJS({
|
|
|
329517
329530
|
"package.json"(exports, module) {
|
|
329518
329531
|
module.exports = {
|
|
329519
329532
|
name: "@validate.qa/runner",
|
|
329520
|
-
version: "1.0.
|
|
329533
|
+
version: "1.0.27",
|
|
329521
329534
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329522
329535
|
bin: {
|
|
329523
329536
|
"validate-runner": "dist/cli.js"
|
package/package.json
CHANGED