@leera.io/qa-runner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +333 -0
  3. package/dist/src/appium/drivers.js +60 -0
  4. package/dist/src/appium/home.js +60 -0
  5. package/dist/src/appium/server.js +237 -0
  6. package/dist/src/builds/cache.js +133 -0
  7. package/dist/src/builds/cleanup.js +208 -0
  8. package/dist/src/builds/download.js +64 -0
  9. package/dist/src/builds/install.js +75 -0
  10. package/dist/src/builds/ios-app.js +99 -0
  11. package/dist/src/builds/unpack.js +347 -0
  12. package/dist/src/capabilities.js +93 -0
  13. package/dist/src/ci/builds.js +68 -0
  14. package/dist/src/ci/client.js +90 -0
  15. package/dist/src/ci/env.js +118 -0
  16. package/dist/src/ci/junit.js +79 -0
  17. package/dist/src/ci/run.js +320 -0
  18. package/dist/src/ci/summary.js +126 -0
  19. package/dist/src/cli/commands/builds.js +104 -0
  20. package/dist/src/cli/commands/ci.js +167 -0
  21. package/dist/src/cli/commands/config.js +83 -0
  22. package/dist/src/cli/commands/connect.js +70 -0
  23. package/dist/src/cli/commands/doctor.js +57 -0
  24. package/dist/src/cli/commands/service.js +165 -0
  25. package/dist/src/cli/commands/setup.js +193 -0
  26. package/dist/src/cli/commands/start.js +94 -0
  27. package/dist/src/cli/commands/update.js +189 -0
  28. package/dist/src/cli/commands/version.js +28 -0
  29. package/dist/src/cli/main.js +59 -0
  30. package/dist/src/cli/output.js +67 -0
  31. package/dist/src/cli.js +40 -0
  32. package/dist/src/client.js +146 -0
  33. package/dist/src/config.js +260 -0
  34. package/dist/src/debug.js +137 -0
  35. package/dist/src/devices/android/adb.js +251 -0
  36. package/dist/src/devices/android/avd.js +95 -0
  37. package/dist/src/devices/android/emulator.js +153 -0
  38. package/dist/src/devices/android/index.js +133 -0
  39. package/dist/src/devices/android/logcat.js +205 -0
  40. package/dist/src/devices/android/prepare.js +40 -0
  41. package/dist/src/devices/android/setup.js +162 -0
  42. package/dist/src/devices/android/system-dialog.js +78 -0
  43. package/dist/src/devices/desktop.js +145 -0
  44. package/dist/src/devices/ios/devicectl.js +87 -0
  45. package/dist/src/devices/ios/index.js +130 -0
  46. package/dist/src/devices/ios/prepare.js +39 -0
  47. package/dist/src/devices/ios/record.js +122 -0
  48. package/dist/src/devices/ios/settings.js +55 -0
  49. package/dist/src/devices/ios/setup.js +186 -0
  50. package/dist/src/devices/ios/simctl.js +201 -0
  51. package/dist/src/devices/ios/syslog.js +185 -0
  52. package/dist/src/devices/ios/wda.js +108 -0
  53. package/dist/src/devices/macos.js +248 -0
  54. package/dist/src/devices/manager.js +206 -0
  55. package/dist/src/devices/screen-record.js +95 -0
  56. package/dist/src/devices/tauri.js +189 -0
  57. package/dist/src/devices/types.js +6 -0
  58. package/dist/src/devices/windows.js +362 -0
  59. package/dist/src/doctor.js +394 -0
  60. package/dist/src/download.js +39 -0
  61. package/dist/src/drivers/android-appium.js +525 -0
  62. package/dist/src/drivers/common.js +64 -0
  63. package/dist/src/drivers/desktop-appium.js +282 -0
  64. package/dist/src/drivers/driver.js +15 -0
  65. package/dist/src/drivers/electron-playwright.js +566 -0
  66. package/dist/src/drivers/ios-appium.js +535 -0
  67. package/dist/src/drivers/mac2-appium.js +313 -0
  68. package/dist/src/drivers/registry.js +47 -0
  69. package/dist/src/drivers/snapshot/aria.js +351 -0
  70. package/dist/src/drivers/snapshot/format.js +106 -0
  71. package/dist/src/drivers/tauri-webdriver.js +678 -0
  72. package/dist/src/drivers/web-playwright.js +403 -0
  73. package/dist/src/drivers/webdriver/actions.js +328 -0
  74. package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
  75. package/dist/src/drivers/webdriver/dom-locate.js +113 -0
  76. package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
  77. package/dist/src/drivers/webdriver/dom-tree.js +118 -0
  78. package/dist/src/drivers/webdriver/dom.js +213 -0
  79. package/dist/src/drivers/webdriver/ios-actions.js +299 -0
  80. package/dist/src/drivers/webdriver/ios-locate.js +121 -0
  81. package/dist/src/drivers/webdriver/locate.js +136 -0
  82. package/dist/src/drivers/webdriver/native-actions.js +175 -0
  83. package/dist/src/drivers/webdriver/native-locate.js +273 -0
  84. package/dist/src/drivers/webdriver/session.js +105 -0
  85. package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
  86. package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
  87. package/dist/src/drivers/webdriver/xml-tree.js +393 -0
  88. package/dist/src/drivers/windows-appium.js +319 -0
  89. package/dist/src/errors.js +21 -0
  90. package/dist/src/executor.js +189 -0
  91. package/dist/src/home.js +76 -0
  92. package/dist/src/index.js +22 -0
  93. package/dist/src/log.js +69 -0
  94. package/dist/src/runner.js +498 -0
  95. package/dist/src/service/index.js +64 -0
  96. package/dist/src/service/launchd.js +85 -0
  97. package/dist/src/service/names.js +2 -0
  98. package/dist/src/service/schtasks.js +128 -0
  99. package/dist/src/service/systemd.js +65 -0
  100. package/dist/src/session/commands.js +165 -0
  101. package/dist/src/session/execute.js +232 -0
  102. package/dist/src/session/loop.js +148 -0
  103. package/dist/src/template.js +62 -0
  104. package/dist/src/types.js +7 -0
  105. package/dist/src/update/apply.js +223 -0
  106. package/dist/src/update/check.js +59 -0
  107. package/dist/src/update/manifest.js +93 -0
  108. package/dist/src/update/verify.js +65 -0
  109. package/dist/src/version.js +42 -0
  110. package/package.json +44 -0
@@ -0,0 +1,136 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ /** Android locator keys (plan §3); exactly one is set on a target. */
3
+ export const ANDROID_LOCATORS = ['id', 'desc', 'text', 'test_id', 'class', 'uiautomator', 'xpath'];
4
+ /** Escapes a Java string literal inside a UiSelector expression. */
5
+ export function escapeUiSelector(value) {
6
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
7
+ }
8
+ /** Escapes a value for a Java regex (`resourceIdMatches`, `classNameMatches`). */
9
+ export function escapeJavaRegex(value) {
10
+ return value.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&');
11
+ }
12
+ function ui(expression) {
13
+ return { using: '-android uiautomator', value: `new UiSelector().${expression}` };
14
+ }
15
+ /**
16
+ * Maps one android target (without fallbacks/nth) to the selectors to try, in order.
17
+ * `render` resolves `{{placeholders}}` in the value first.
18
+ *
19
+ * - `id`: `com.acme:id/login` exactly; a bare `login` matches `<any package>:id/login`.
20
+ * - `desc`: exact content-description unless `exact: false` (then contains).
21
+ * - `text`: contains unless `exact: true`.
22
+ * - `test_id` (React Native / Expo `testID`): resource-id equal to the value, then
23
+ * `<package>:id/<value>`, then content-description (accessibility id).
24
+ * - `class`: full class name, or a simple name matched as `….Name`.
25
+ * - `uiautomator`: a UiSelector expression, used as is.
26
+ * - `xpath`: used as is.
27
+ */
28
+ export function androidSelectors(target, render = (t) => t) {
29
+ if (target.id !== undefined) {
30
+ const id = render(target.id);
31
+ if (id.includes(':id/'))
32
+ return [ui(`resourceId("${escapeUiSelector(id)}")`)];
33
+ return [ui(`resourceIdMatches("${escapeUiSelector(`.*:id/${escapeJavaRegex(id)}`)}")`)];
34
+ }
35
+ if (target.desc !== undefined) {
36
+ const desc = escapeUiSelector(render(target.desc));
37
+ return [ui(target.exact === false ? `descriptionContains("${desc}")` : `description("${desc}")`)];
38
+ }
39
+ if (target.text !== undefined) {
40
+ const text = escapeUiSelector(render(target.text));
41
+ return [ui(target.exact === true ? `text("${text}")` : `textContains("${text}")`)];
42
+ }
43
+ if (target.test_id !== undefined) {
44
+ const raw = render(target.test_id);
45
+ const value = escapeUiSelector(raw);
46
+ return [
47
+ ui(`resourceId("${value}")`),
48
+ ui(`resourceIdMatches("${escapeUiSelector(`.*:id/${escapeJavaRegex(raw)}`)}")`),
49
+ { using: 'accessibility id', value: raw },
50
+ ];
51
+ }
52
+ if (target.class !== undefined) {
53
+ const cls = render(target.class);
54
+ if (cls.includes('.'))
55
+ return [ui(`className("${escapeUiSelector(cls)}")`)];
56
+ return [ui(`classNameMatches("${escapeUiSelector(`.*\\.${escapeJavaRegex(cls)}`)}")`)];
57
+ }
58
+ if (target.uiautomator !== undefined) {
59
+ const expression = render(target.uiautomator).trim();
60
+ return [{ using: '-android uiautomator', value: expression }];
61
+ }
62
+ if (target.xpath !== undefined)
63
+ return [{ using: 'xpath', value: render(target.xpath) }];
64
+ const named = Object.keys(target).filter((key) => !['exact', 'nth', 'fallbacks', 'locate'].includes(key));
65
+ throw new Error(named.length > 0
66
+ ? `'${named[0]}' is not an android locator; use one of ${ANDROID_LOCATORS.join(', ')}`
67
+ : 'the target names no locator');
68
+ }
69
+ export function describeAndroidTarget(target) {
70
+ if (target.desc !== undefined)
71
+ return `element described "${target.desc}"`;
72
+ if (target.id !== undefined)
73
+ return `id "${target.id}"`;
74
+ if (target.text !== undefined)
75
+ return `text "${target.text}"`;
76
+ if (target.test_id !== undefined)
77
+ return `test id "${target.test_id}"`;
78
+ if (target.class !== undefined)
79
+ return `class ${target.class}`;
80
+ if (target.uiautomator !== undefined)
81
+ return `UiSelector ${target.uiautomator}`;
82
+ if (target.xpath !== undefined)
83
+ return `xpath ${target.xpath}`;
84
+ return 'element';
85
+ }
86
+ export function elementIdOf(reference) {
87
+ if (reference && typeof reference === 'object') {
88
+ for (const [key, value] of Object.entries(reference)) {
89
+ if ((key === 'element-6066-11e4-a52e-4f735466cecf' || key === 'element-6066-11e4-a52f-4ce4f6d4b2fb' || key === 'ELEMENT') && typeof value === 'string')
90
+ return value;
91
+ }
92
+ }
93
+ throw new Error('the driver returned no element reference');
94
+ }
95
+ /** Elements matching one target (no fallbacks), in document order. */
96
+ export async function findAll(client, target, render) {
97
+ for (const selector of androidSelectors(target, render)) {
98
+ const found = await client.findElements(selector.using, selector.value).catch((error) => {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ // An invalid UiSelector/xpath is the script's problem, not a missing element.
101
+ if (/invalid selector|InvalidSelector|could not parse|UiSelector/i.test(message) && !/no such element/i.test(message))
102
+ throw error;
103
+ return [];
104
+ });
105
+ if (found.length > 0)
106
+ return found.map(elementIdOf);
107
+ }
108
+ return [];
109
+ }
110
+ /** Candidates in order: the target itself, then its fallbacks. */
111
+ export function candidates(target) {
112
+ const { fallbacks, ...primary } = target;
113
+ return [primary, ...(fallbacks ?? []).map(({ fallbacks: _nested, ...rest }) => rest)];
114
+ }
115
+ /** One pass over the target and its fallbacks; null when nothing matches (at `nth`). */
116
+ export async function locateOnce(client, target, render) {
117
+ for (const candidate of candidates(target)) {
118
+ const ids = await findAll(client, candidate, render);
119
+ const nth = candidate.nth ?? 0;
120
+ if (ids.length > nth)
121
+ return { elementId: ids[nth], target: candidate, count: ids.length };
122
+ }
123
+ return null;
124
+ }
125
+ /** Polls until the target (or a fallback) matches or `timeoutMs` passes. */
126
+ export async function locate(client, target, timeoutMs, render, pollMs = 300) {
127
+ const deadline = Date.now() + timeoutMs;
128
+ for (;;) {
129
+ const found = await locateOnce(client, target, render);
130
+ if (found)
131
+ return found;
132
+ if (Date.now() >= deadline)
133
+ return null;
134
+ await sleep(pollMs);
135
+ }
136
+ }
@@ -0,0 +1,175 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { clip, expectWithin, matches, verb } from '../common.js';
3
+ import { NotFound, Unsupported } from '../driver.js';
4
+ import { parseDesktopKey } from './desktop-keys.js';
5
+ import { nativeFindAll, nativeLocate } from './native-locate.js';
6
+ import { webdriverMessage } from './session.js';
7
+ /**
8
+ * Native desktop DSL actions (contracts §M) shared by the Mac2 and Windows drivers. The app
9
+ * lifecycle actions (`launch`, `terminate`, `focus_window`) are the drivers' own.
10
+ */
11
+ export const NATIVE_DESKTOP_ACTIONS = [
12
+ 'launch',
13
+ 'terminate',
14
+ 'focus_window',
15
+ 'click',
16
+ 'double_click',
17
+ 'right_click',
18
+ 'hover',
19
+ 'type',
20
+ 'clear',
21
+ 'press',
22
+ 'check',
23
+ 'uncheck',
24
+ 'select_option',
25
+ 'wait',
26
+ 'screenshot',
27
+ 'wait_for',
28
+ 'expect_visible',
29
+ 'expect_hidden',
30
+ 'expect_text',
31
+ ];
32
+ function requireTarget(action) {
33
+ if (!action.target)
34
+ throw new Error(`'${action.type}' needs a target`);
35
+ return action.target;
36
+ }
37
+ async function element(ctx, target, timeoutMs) {
38
+ const found = await nativeLocate(ctx.client, ctx.ops.selectors, target, timeoutMs, ctx.render);
39
+ if (!found)
40
+ throw new NotFound(`${ctx.ops.describe(target)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
41
+ ctx.onLocated?.(found);
42
+ return found;
43
+ }
44
+ async function visibleCount(ctx, target) {
45
+ const ids = await nativeFindAll(ctx.client, ctx.ops.selectors, { ...target, fallbacks: undefined }, ctx.render);
46
+ const pool = target.nth === undefined ? ids : ids.slice(target.nth, target.nth + 1);
47
+ let count = 0;
48
+ for (const id of pool) {
49
+ if (await ctx.client.isElementDisplayed(id).catch(() => false))
50
+ count += 1;
51
+ }
52
+ return count;
53
+ }
54
+ /** Retries a command while the element is still settling (stale, not yet interactable). */
55
+ async function retrying(ctx, target, timeoutMs, command) {
56
+ const deadline = Date.now() + timeoutMs;
57
+ let found = await element(ctx, target, timeoutMs);
58
+ for (;;) {
59
+ try {
60
+ await command(found.elementId);
61
+ return;
62
+ }
63
+ catch (error) {
64
+ const message = webdriverMessage(error);
65
+ if (Date.now() >= deadline || !/stale element|not interactable|no longer attached|not hittable|element not/i.test(message))
66
+ throw error;
67
+ await sleep(200);
68
+ found = await element(ctx, target, Math.max(100, deadline - Date.now()));
69
+ }
70
+ }
71
+ }
72
+ /** Performs one native desktop DSL action other than launch/terminate/focus_window. */
73
+ export async function runNativeAction(ctx, action) {
74
+ const timeout = action.timeout_ms ?? ctx.defaultTimeoutMs;
75
+ const { client, ops } = ctx;
76
+ switch (action.type) {
77
+ case 'click':
78
+ await retrying(ctx, requireTarget(action), timeout, (id) => ops.click(id));
79
+ return {};
80
+ case 'double_click':
81
+ await retrying(ctx, requireTarget(action), timeout, (id) => ops.doubleClick(id));
82
+ return {};
83
+ case 'right_click':
84
+ await retrying(ctx, requireTarget(action), timeout, (id) => ops.rightClick(id));
85
+ return {};
86
+ case 'hover':
87
+ await retrying(ctx, requireTarget(action), timeout, (id) => ops.hover(id));
88
+ return {};
89
+ case 'type': {
90
+ const value = ctx.render(action.value ?? '');
91
+ await retrying(ctx, requireTarget(action), timeout, async (id) => {
92
+ if (action.clear)
93
+ await client.elementClear(id);
94
+ if (value)
95
+ await client.elementSendKeys(id, value);
96
+ });
97
+ return {};
98
+ }
99
+ case 'clear':
100
+ await retrying(ctx, requireTarget(action), timeout, (id) => client.elementClear(id));
101
+ return {};
102
+ case 'press': {
103
+ const chord = parseDesktopKey(action.key ?? '');
104
+ const found = action.target ? await element(ctx, action.target, timeout) : null;
105
+ await ops.press(chord, found?.elementId ?? null);
106
+ return {};
107
+ }
108
+ case 'check':
109
+ case 'uncheck': {
110
+ const wanted = action.type === 'check';
111
+ const target = requireTarget(action);
112
+ const found = await element(ctx, target, timeout);
113
+ const current = await ops.isChecked(found.elementId);
114
+ if (current === null)
115
+ throw new Error(`${ops.describe(target)} is not a checkbox, switch or radio button`);
116
+ if (current !== wanted)
117
+ await ops.click(found.elementId);
118
+ await expectWithin(Math.min(timeout, 3_000), async () => (await ops.isChecked(found.elementId)) === wanted, `${ops.describe(target)} to be ${wanted ? 'checked' : 'unchecked'}`);
119
+ return {};
120
+ }
121
+ case 'select_option': {
122
+ const found = await element(ctx, requireTarget(action), timeout);
123
+ await ops.selectOption(found.elementId, ctx.render(action.value ?? ''), timeout);
124
+ return {};
125
+ }
126
+ case 'wait':
127
+ await sleep(Math.min(Math.max(action.ms ?? 0, 0), 30_000));
128
+ return {};
129
+ case 'screenshot':
130
+ return { screenshot: Buffer.from(await client.takeScreenshot(), 'base64') };
131
+ case 'wait_for':
132
+ case 'expect_visible': {
133
+ const target = requireTarget(action);
134
+ await expectWithin(timeout, async () => {
135
+ const found = await nativeLocate(client, ops.selectors, target, 0, ctx.render);
136
+ if (!found)
137
+ return false;
138
+ const shown = await client.isElementDisplayed(found.elementId).catch(() => false);
139
+ if (shown)
140
+ ctx.onLocated?.(found);
141
+ return shown;
142
+ }, `${ops.describe(target)} to be visible`);
143
+ return {};
144
+ }
145
+ case 'expect_hidden': {
146
+ const target = requireTarget(action);
147
+ await expectWithin(timeout, async () => (await visibleCount(ctx, target)) === 0, `${ops.describe(target)} to be hidden`);
148
+ return {};
149
+ }
150
+ case 'expect_text': {
151
+ const target = requireTarget(action);
152
+ const expected = ctx.render(action.value ?? '');
153
+ let actual = '';
154
+ await expectWithin(timeout, async () => {
155
+ const found = await nativeLocate(client, ops.selectors, target, 0, ctx.render);
156
+ if (!found) {
157
+ actual = '(not found)';
158
+ return false;
159
+ }
160
+ actual = await ops.text(found.elementId);
161
+ return matches(actual, expected, action.match);
162
+ }, () => `text of ${ops.describe(target)} to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
163
+ return {};
164
+ }
165
+ case 'tap':
166
+ throw new Unsupported(`'tap' is not a ${ops.platform} action; use click`);
167
+ case 'fill':
168
+ throw new Unsupported(`'fill' is not a ${ops.platform} action; use type`);
169
+ case 'goto':
170
+ case 'deep_link':
171
+ throw new Unsupported(`'${action.type}' is not a ${ops.platform} action; start the app with launch`);
172
+ default:
173
+ throw new Unsupported(`'${action.type}' is not a ${ops.platform} action`);
174
+ }
175
+ }
@@ -0,0 +1,273 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { Unsupported } from '../driver.js';
3
+ import { predicateString } from './ios-locate.js';
4
+ import { candidates, elementIdOf } from './locate.js';
5
+ const NON_LOCATOR_KEYS = ['exact', 'nth', 'fallbacks', 'locate'];
6
+ const MAX_EXPRESSION_CHARS = 500;
7
+ function namedKeys(target) {
8
+ return Object.keys(target).filter((key) => !NON_LOCATOR_KEYS.includes(key));
9
+ }
10
+ function checkLength(kind, value) {
11
+ if (value.length > MAX_EXPRESSION_CHARS)
12
+ throw new Unsupported(`${kind} must be at most ${MAX_EXPRESSION_CHARS} characters`);
13
+ return value;
14
+ }
15
+ // ---------------------------------------------------------------------------------------------
16
+ // macOS (Appium Mac2 / WebDriverAgentMac)
17
+ // ---------------------------------------------------------------------------------------------
18
+ /** macOS locator keys (plan §3, contracts §M): no `xpath` on macOS. */
19
+ export const MACOS_LOCATORS = ['identifier', 'title', 'role', 'predicate', 'class_chain'];
20
+ /** Accessibility roles → XCUIElementType names (what Mac2 queries and its page source use). */
21
+ export const MAC_TYPE_BY_ROLE = {
22
+ AXApplication: 'Application',
23
+ AXWindow: 'Window',
24
+ AXSheet: 'Sheet',
25
+ AXDrawer: 'Drawer',
26
+ AXDialog: 'Dialog',
27
+ AXButton: 'Button',
28
+ AXRadioButton: 'RadioButton',
29
+ AXRadioGroup: 'RadioGroup',
30
+ AXCheckBox: 'CheckBox',
31
+ AXDisclosureTriangle: 'DisclosureTriangle',
32
+ AXPopUpButton: 'PopUpButton',
33
+ AXComboBox: 'ComboBox',
34
+ AXMenuButton: 'MenuButton',
35
+ AXPopover: 'Popover',
36
+ AXToolbar: 'Toolbar',
37
+ AXTabGroup: 'TabGroup',
38
+ AXTable: 'Table',
39
+ AXRow: 'TableRow',
40
+ AXColumn: 'TableColumn',
41
+ AXOutline: 'Outline',
42
+ AXBrowser: 'Browser',
43
+ AXList: 'CollectionView',
44
+ AXSlider: 'Slider',
45
+ AXProgressIndicator: 'ProgressIndicator',
46
+ AXBusyIndicator: 'ActivityIndicator',
47
+ AXLink: 'Link',
48
+ AXImage: 'Image',
49
+ AXSearchField: 'SearchField',
50
+ AXScrollArea: 'ScrollView',
51
+ AXScrollBar: 'ScrollBar',
52
+ AXStaticText: 'StaticText',
53
+ AXTextField: 'TextField',
54
+ AXSecureTextField: 'SecureTextField',
55
+ AXDateField: 'DatePicker',
56
+ AXTextArea: 'TextView',
57
+ AXMenu: 'Menu',
58
+ AXMenuItem: 'MenuItem',
59
+ AXMenuBar: 'MenuBar',
60
+ AXMenuBarItem: 'MenuBarItem',
61
+ AXWebArea: 'WebView',
62
+ AXIncrementor: 'Stepper',
63
+ AXSplitGroup: 'SplitGroup',
64
+ AXSplitter: 'Splitter',
65
+ AXColorWell: 'ColorWell',
66
+ AXHelpTag: 'HelpTag',
67
+ AXGrid: 'Grid',
68
+ AXLevelIndicator: 'LevelIndicator',
69
+ AXCell: 'Cell',
70
+ AXLayoutArea: 'LayoutArea',
71
+ AXLayoutItem: 'LayoutItem',
72
+ AXHandle: 'Handle',
73
+ AXRuler: 'Ruler',
74
+ AXRulerMarker: 'RulerMarker',
75
+ AXGroup: 'Group',
76
+ AXSegmentedControl: 'SegmentedControl',
77
+ AXSwitch: 'Switch',
78
+ AXToggle: 'Toggle',
79
+ AXValueIndicator: 'ValueIndicator',
80
+ AXDockItem: 'DockItem',
81
+ AXMatte: 'Matte',
82
+ AXUnknown: 'Other',
83
+ };
84
+ /** XCUIElementType name (without prefix) → accessibility role; the first role wins for shared types. */
85
+ export const MAC_ROLE_BY_TYPE = Object.fromEntries(Object.entries(MAC_TYPE_BY_ROLE)
86
+ .reverse()
87
+ .map(([role, type]) => [type, role]));
88
+ /** `AXButton` → `XCUIElementTypeButton`; throws for roles WebDriverAgentMac has no element type for. */
89
+ export function macElementType(role) {
90
+ const trimmed = role.trim();
91
+ if (!/^AX[A-Za-z]+$/.test(trimmed))
92
+ throw new Unsupported(`role '${role}' is not an accessibility role (for example AXButton)`);
93
+ const type = MAC_TYPE_BY_ROLE[trimmed];
94
+ if (!type)
95
+ throw new Unsupported(`role '${trimmed}' has no macOS element type the runner can query; use predicate or class_chain`);
96
+ return `XCUIElementType${type}`;
97
+ }
98
+ /**
99
+ * Maps one macos target (without fallbacks/nth) to Mac2 selectors:
100
+ * - `identifier`: the accessibility identifier (`accessibility id`).
101
+ * - `title`: title or label, exact unless `exact: false` (then contains).
102
+ * - `role`: an AX role, queried by element type.
103
+ * - `predicate`, `class_chain`: used as they are.
104
+ * There is no `xpath` locator on macOS (the server refuses it too).
105
+ */
106
+ export function macSelectors(target, render = (t) => t) {
107
+ if (target.identifier !== undefined)
108
+ return [{ using: 'accessibility id', value: render(target.identifier) }];
109
+ if (target.title !== undefined) {
110
+ const title = predicateString(render(target.title));
111
+ const op = target.exact === false ? 'CONTAINS' : '==';
112
+ return [{ using: '-ios predicate string', value: `title ${op} ${title} OR label ${op} ${title}` }];
113
+ }
114
+ if (target.role !== undefined)
115
+ return [{ using: 'class name', value: macElementType(render(target.role)) }];
116
+ if (target.predicate !== undefined)
117
+ return [{ using: '-ios predicate string', value: checkLength('predicate', render(target.predicate).trim()) }];
118
+ if (target.class_chain !== undefined)
119
+ return [{ using: '-ios class chain', value: checkLength('class_chain', render(target.class_chain).trim()) }];
120
+ const named = namedKeys(target);
121
+ throw new Unsupported(named.length > 0 ? `'${named[0]}' is not a macos locator; use one of ${MACOS_LOCATORS.join(', ')}` : 'the target names no locator');
122
+ }
123
+ export function describeMacTarget(target) {
124
+ if (target.identifier !== undefined)
125
+ return `identifier "${target.identifier}"`;
126
+ if (target.title !== undefined)
127
+ return `element titled "${target.title}"`;
128
+ if (target.role !== undefined)
129
+ return target.nth !== undefined ? `${target.role} #${target.nth + 1}` : target.role;
130
+ if (target.predicate !== undefined)
131
+ return `predicate ${target.predicate}`;
132
+ if (target.class_chain !== undefined)
133
+ return `class chain ${target.class_chain}`;
134
+ return 'element';
135
+ }
136
+ // ---------------------------------------------------------------------------------------------
137
+ // Windows (Appium Windows driver / WinAppDriver, UI Automation)
138
+ // ---------------------------------------------------------------------------------------------
139
+ export const WINDOWS_LOCATORS = ['automation_id', 'name', 'class_name', 'control_type', 'xpath'];
140
+ /** UI Automation control types a `control_type` locator may name (contracts §M). */
141
+ export const WINDOWS_CONTROL_TYPES = [
142
+ 'Button',
143
+ 'Edit',
144
+ 'Text',
145
+ 'CheckBox',
146
+ 'ComboBox',
147
+ 'List',
148
+ 'ListItem',
149
+ 'MenuItem',
150
+ 'Tab',
151
+ 'TabItem',
152
+ 'Tree',
153
+ 'TreeItem',
154
+ 'Window',
155
+ 'Pane',
156
+ 'Document',
157
+ 'Hyperlink',
158
+ 'Image',
159
+ 'RadioButton',
160
+ 'Slider',
161
+ 'Spinner',
162
+ 'ToolBar',
163
+ 'DataGrid',
164
+ 'DataItem',
165
+ 'Custom',
166
+ ];
167
+ /** Quotes a string for an XPath 1.0 literal (no escapes exist: split around quotes with concat). */
168
+ export function xpathLiteral(value) {
169
+ if (!value.includes('"'))
170
+ return `"${value}"`;
171
+ if (!value.includes("'"))
172
+ return `'${value}'`;
173
+ return `concat(${value
174
+ .split('"')
175
+ .map((part) => `"${part}"`)
176
+ .join(`, '"', `)})`;
177
+ }
178
+ /**
179
+ * Maps one windows target (without fallbacks/nth) to WinAppDriver selectors:
180
+ * - `automation_id`: the UIA AutomationId (`accessibility id`).
181
+ * - `name`: the UIA Name, exact unless `exact: false` (then an xpath `contains`).
182
+ * - `class_name`: the UIA ClassName.
183
+ * - `control_type`: the control type as the page source's tag (`//Button`), locale independent.
184
+ * - `xpath`: used as is.
185
+ */
186
+ export function windowsSelectors(target, render = (t) => t) {
187
+ if (target.automation_id !== undefined)
188
+ return [{ using: 'accessibility id', value: render(target.automation_id) }];
189
+ if (target.name !== undefined) {
190
+ const name = render(target.name);
191
+ if (target.exact === false)
192
+ return [{ using: 'xpath', value: `//*[contains(@Name, ${xpathLiteral(name)})]` }];
193
+ return [{ using: 'name', value: name }];
194
+ }
195
+ if (target.class_name !== undefined)
196
+ return [{ using: 'class name', value: render(target.class_name) }];
197
+ if (target.control_type !== undefined) {
198
+ const type = render(target.control_type).trim();
199
+ if (!WINDOWS_CONTROL_TYPES.includes(type)) {
200
+ throw new Unsupported(`control_type '${type}' is not a UI Automation control type; use one of ${WINDOWS_CONTROL_TYPES.join(', ')}`);
201
+ }
202
+ return [{ using: 'xpath', value: `//${type}` }];
203
+ }
204
+ if (target.xpath !== undefined)
205
+ return [{ using: 'xpath', value: checkLength('xpath', render(target.xpath)) }];
206
+ const named = namedKeys(target);
207
+ throw new Unsupported(named.length > 0 ? `'${named[0]}' is not a windows locator; use one of ${WINDOWS_LOCATORS.join(', ')}` : 'the target names no locator');
208
+ }
209
+ export function describeWindowsTarget(target) {
210
+ if (target.automation_id !== undefined)
211
+ return `automation id "${target.automation_id}"`;
212
+ if (target.name !== undefined)
213
+ return `element named "${target.name}"`;
214
+ if (target.class_name !== undefined)
215
+ return `class ${target.class_name}`;
216
+ if (target.control_type !== undefined)
217
+ return target.nth !== undefined ? `${target.control_type} #${target.nth + 1}` : target.control_type;
218
+ if (target.xpath !== undefined)
219
+ return `xpath ${target.xpath}`;
220
+ return 'element';
221
+ }
222
+ // ---------------------------------------------------------------------------------------------
223
+ // Shared lookup
224
+ // ---------------------------------------------------------------------------------------------
225
+ /**
226
+ * A ref from a snapshot carries where the element was (runner-private `locate`): try the
227
+ * suggestion first, then that position (Windows `xpath`, macOS `class_chain`).
228
+ */
229
+ export function resolveNativeRef(target) {
230
+ const { locate, ...rest } = target;
231
+ const position = typeof locate?.class_chain === 'string' ? { class_chain: locate.class_chain } : typeof locate?.xpath === 'string' ? { xpath: locate.xpath } : null;
232
+ if (!position)
233
+ return rest;
234
+ return { ...rest, fallbacks: [...(rest.fallbacks ?? []), position] };
235
+ }
236
+ /** Elements matching one target (no fallbacks), in document order. */
237
+ export async function nativeFindAll(client, selectors, target, render) {
238
+ for (const selector of selectors(target, render)) {
239
+ const found = await client.findElements(selector.using, selector.value).catch((error) => {
240
+ const message = error instanceof Error ? error.message : String(error);
241
+ // A malformed predicate, class chain or xpath is the script's problem, not a missing element.
242
+ if (/invalid selector|InvalidSelector|Unable to parse|parse the format|is not a valid|Invalid class chain|Invalid locator/i.test(message) && !/no such element/i.test(message)) {
243
+ throw error;
244
+ }
245
+ return [];
246
+ });
247
+ if (found.length > 0)
248
+ return found.map(elementIdOf);
249
+ }
250
+ return [];
251
+ }
252
+ /** One pass over the target and its fallbacks; null when nothing matches (at `nth`). */
253
+ export async function nativeLocateOnce(client, selectors, target, render) {
254
+ for (const candidate of candidates(target)) {
255
+ const ids = await nativeFindAll(client, selectors, candidate, render);
256
+ const nth = candidate.nth ?? 0;
257
+ if (ids.length > nth)
258
+ return { elementId: ids[nth], target: candidate, count: ids.length };
259
+ }
260
+ return null;
261
+ }
262
+ /** Polls until the target (or a fallback) matches or `timeoutMs` passes. */
263
+ export async function nativeLocate(client, selectors, target, timeoutMs, render, pollMs = 300) {
264
+ const deadline = Date.now() + timeoutMs;
265
+ for (;;) {
266
+ const found = await nativeLocateOnce(client, selectors, target, render);
267
+ if (found)
268
+ return found;
269
+ if (Date.now() >= deadline)
270
+ return null;
271
+ await sleep(pollMs);
272
+ }
273
+ }