@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,213 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { Unsupported } from '../driver.js';
4
+ import { elementIdOf } from './locate.js';
5
+ /**
6
+ * Locators for WebDriver-backed web views (Tauri, contracts §L). Elements are found by the
7
+ * runner-owned `dom-locate.js`, executed with W3C `execute/sync`; no other script is ever run.
8
+ */
9
+ export const DOM_LOCATORS = ['css', 'text', 'test_id', 'xpath'];
10
+ export const MAX_XPATH_CHARS = 500;
11
+ /** Elements one locate call returns (a `nth` beyond it is fetched on demand). */
12
+ export const DOM_LOCATE_LIMIT = 50;
13
+ const scripts = new Map();
14
+ /** The file as shipped next to this module (copied into dist by the build). */
15
+ export function domScriptSource(name) {
16
+ return readFileSync(new URL(`./${name}.js`, import.meta.url), 'utf8');
17
+ }
18
+ /** The `execute/sync` body for a runner-owned script: the file's function applied to the arguments. */
19
+ export function domScript(name) {
20
+ let script = scripts.get(name);
21
+ if (!script) {
22
+ const source = domScriptSource(name)
23
+ .replace(/^\s*\/\*[\s\S]*?\*\/\s*/, '')
24
+ .trim();
25
+ if (!source.startsWith('(function') || !source.endsWith(')'))
26
+ throw new Error(`${name}.js is not a function expression`);
27
+ script = `return (${source}).apply(null, arguments);`;
28
+ scripts.set(name, script);
29
+ }
30
+ return script;
31
+ }
32
+ /** W3C web element reference for script arguments and actions. */
33
+ export const ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
34
+ export function elementRef(elementId) {
35
+ return { [ELEMENT_KEY]: elementId };
36
+ }
37
+ /** The locator key a target uses, after checking it is one tauri understands. */
38
+ export function domLocatorKey(target, platform = 'tauri') {
39
+ const keys = Object.keys(target).filter((key) => !['exact', 'nth', 'fallbacks', 'locate'].includes(key) && target[key] !== undefined);
40
+ const foreign = keys.find((key) => !DOM_LOCATORS.includes(key));
41
+ if (foreign)
42
+ throw new Unsupported(`target.${foreign} is not a ${platform} locator; use css, text, test_id or xpath`);
43
+ if (keys.length !== 1)
44
+ throw new Unsupported('target needs exactly one of css, text, test_id, xpath');
45
+ const key = keys[0];
46
+ const value = target[key];
47
+ if (typeof value !== 'string' || value.length === 0)
48
+ throw new Unsupported(`target.${key} must be a non-empty string`);
49
+ if (key === 'xpath' && value.length > MAX_XPATH_CHARS)
50
+ throw new Unsupported(`target.xpath is longer than ${MAX_XPATH_CHARS} characters`);
51
+ if (target.nth !== undefined && (!Number.isInteger(target.nth) || target.nth < 0 || target.nth > 1_000)) {
52
+ throw new Unsupported('target.nth must be a whole number from 0 to 1000');
53
+ }
54
+ return key;
55
+ }
56
+ /** Checks a target and its fallbacks (≤5, not nested) before anything runs. */
57
+ export function validateDomTarget(target, platform = 'tauri') {
58
+ domLocatorKey(target, platform);
59
+ const fallbacks = target.fallbacks ?? [];
60
+ if (!Array.isArray(fallbacks) || fallbacks.length > 5)
61
+ throw new Unsupported('target.fallbacks must be a list of at most 5 targets');
62
+ fallbacks.forEach((fallback, i) => {
63
+ if (fallback.fallbacks !== undefined)
64
+ throw new Unsupported(`target.fallbacks[${i}] cannot have fallbacks`);
65
+ try {
66
+ domLocatorKey(fallback, platform);
67
+ }
68
+ catch (error) {
69
+ throw new Unsupported(error.message.replace(/^target\./, `target.fallbacks[${i}].`));
70
+ }
71
+ });
72
+ }
73
+ /** The request `dom-locate.js` takes for one target (placeholders rendered). */
74
+ export function domLocateRequest(target, render = (t) => t, limit = DOM_LOCATE_LIMIT) {
75
+ const key = domLocatorKey(target);
76
+ const value = render(target[key]);
77
+ const request = { [key]: value };
78
+ if (key === 'text' && target.exact !== undefined)
79
+ request.exact = target.exact;
80
+ return { target: request, limit: Math.max(1, Math.min(1_000, limit)) };
81
+ }
82
+ /** Parses what `dom-locate.js` returned. */
83
+ export function parseDomMatches(raw) {
84
+ if (!raw || typeof raw !== 'object')
85
+ throw new Error('the locator script returned nothing');
86
+ const value = raw;
87
+ const elements = Array.isArray(value.elements) ? value.elements : [];
88
+ const visible = Array.isArray(value.visible) ? value.visible.map((v) => v === true) : [];
89
+ return {
90
+ count: typeof value.count === 'number' ? value.count : elements.length,
91
+ elementIds: elements.map(elementIdOf),
92
+ visible,
93
+ };
94
+ }
95
+ /** Candidates in order: the target itself, then its fallbacks. */
96
+ export function domCandidates(target) {
97
+ const { fallbacks, locate: _locate, ...primary } = target;
98
+ return [primary, ...(fallbacks ?? []).map(({ fallbacks: _nested, locate: _l, ...rest }) => rest)];
99
+ }
100
+ /** All matches of one target (no fallbacks). */
101
+ export async function domFindAll(client, target, render, limit) {
102
+ const raw = await client.executeScript(domScript('dom-locate'), [domLocateRequest(target, render, limit ?? Math.max(DOM_LOCATE_LIMIT, (target.nth ?? 0) + 1))]);
103
+ return parseDomMatches(raw);
104
+ }
105
+ /** One pass over the target and its fallbacks; null when nothing matches at `nth`. */
106
+ export async function domLocateOnce(client, target, render) {
107
+ for (const candidate of domCandidates(target)) {
108
+ const found = await domFindAll(client, candidate, render);
109
+ const nth = candidate.nth ?? 0;
110
+ const elementId = found.elementIds[nth];
111
+ if (elementId)
112
+ return { elementId, target: candidate, count: found.count, visible: found.visible[nth] ?? false };
113
+ }
114
+ return null;
115
+ }
116
+ /** Polls until the target (or a fallback) matches (and is visible, when asked) or the time is up. */
117
+ export async function domLocate(client, target, timeoutMs, options = {}) {
118
+ const deadline = Date.now() + timeoutMs;
119
+ for (;;) {
120
+ const found = await domLocateOnce(client, target, options.render);
121
+ if (found && (!options.visible || found.visible))
122
+ return found;
123
+ if (Date.now() >= deadline)
124
+ return options.visible ? null : found;
125
+ await sleep(options.pollMs ?? 200);
126
+ }
127
+ }
128
+ export function describeDomTarget(target) {
129
+ if (target.test_id !== undefined)
130
+ return `test id "${target.test_id}"`;
131
+ if (target.text !== undefined)
132
+ return `text "${target.text}"`;
133
+ if (target.css !== undefined)
134
+ return `element "${target.css}"`;
135
+ if (target.xpath !== undefined)
136
+ return `xpath ${target.xpath}`;
137
+ return 'element';
138
+ }
139
+ /** W3C WebDriver key codes for named keys (Playwright-style names, case-insensitive). */
140
+ const NAMED_KEYS = {
141
+ enter: '',
142
+ return: '',
143
+ tab: '',
144
+ escape: '',
145
+ esc: '',
146
+ backspace: '',
147
+ delete: '',
148
+ insert: '',
149
+ space: ' ',
150
+ arrowup: '',
151
+ arrowdown: '',
152
+ arrowleft: '',
153
+ arrowright: '',
154
+ up: '',
155
+ down: '',
156
+ left: '',
157
+ right: '',
158
+ home: '',
159
+ end: '',
160
+ pageup: '',
161
+ pagedown: '',
162
+ f1: '',
163
+ f2: '',
164
+ f3: '',
165
+ f4: '',
166
+ f5: '',
167
+ f6: '',
168
+ f7: '',
169
+ f8: '',
170
+ f9: '',
171
+ f10: '',
172
+ f11: '',
173
+ f12: '',
174
+ };
175
+ const MODIFIERS = {
176
+ shift: '',
177
+ control: '',
178
+ ctrl: '',
179
+ alt: '',
180
+ option: '',
181
+ meta: '',
182
+ cmd: '',
183
+ command: '',
184
+ };
185
+ /**
186
+ * A key or chord (`Enter`, `Control+A`, `ctrl+s`, `ControlOrMeta+V`) as WebDriver key values:
187
+ * modifiers first, then the key. Throws `Unsupported` for names it does not know.
188
+ */
189
+ export function webdriverKeys(key, platform = process.platform) {
190
+ const parts = key.length > 1 && key.includes('+') ? key.split('+') : [key];
191
+ const last = parts.pop() ?? '';
192
+ const modifiers = parts.map((part) => {
193
+ const name = part.trim().toLowerCase();
194
+ if (name === 'controlormeta')
195
+ return platform === 'darwin' ? MODIFIERS.meta : MODIFIERS.control;
196
+ const code = MODIFIERS[name];
197
+ if (!code)
198
+ throw new Unsupported(`'${part}' is not a modifier key; use Shift, Control, Alt or Meta`);
199
+ return code;
200
+ });
201
+ if (last.length === 1)
202
+ return { modifiers, key: last };
203
+ const named = NAMED_KEYS[last.toLowerCase()] ?? MODIFIERS[last.toLowerCase()];
204
+ if (!named)
205
+ throw new Unsupported(`'${last}' is not a key this runner knows`);
206
+ return { modifiers, key: named };
207
+ }
208
+ /** A W3C key action sequence pressing and releasing a chord. */
209
+ export function keyActions(keys) {
210
+ const down = [...keys.modifiers, keys.key].map((value) => ({ type: 'keyDown', value }));
211
+ const up = [keys.key, ...[...keys.modifiers].reverse()].map((value) => ({ type: 'keyUp', value }));
212
+ return [{ type: 'key', id: 'keyboard', actions: [...down, ...up] }];
213
+ }
@@ -0,0 +1,299 @@
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 { checkDeepLink, gestureArea, orientationValue, swipeDistance } from './actions.js';
5
+ import { describeIosTarget, iosFindAll, iosLocate } from './ios-locate.js';
6
+ import { elementIdOf } from './locate.js';
7
+ import { mobile, webdriverMessage } from './session.js';
8
+ /** Named ios keys (plan §3). There is no `back` key on iOS. */
9
+ export const IOS_KEYS = ['home', 'return', 'delete', 'volume_up', 'volume_down'];
10
+ /** How a key is pressed: a hardware button, or a character typed into the focused element. */
11
+ export function iosKeyAction(key) {
12
+ switch (key.trim().toLowerCase()) {
13
+ case 'home':
14
+ return { kind: 'button', name: 'home' };
15
+ case 'volume_up':
16
+ return { kind: 'button', name: 'volumeup' };
17
+ case 'volume_down':
18
+ return { kind: 'button', name: 'volumedown' };
19
+ case 'return':
20
+ case 'enter':
21
+ return { kind: 'type', text: '\n' };
22
+ case 'delete':
23
+ return { kind: 'type', text: '\b' };
24
+ case 'back':
25
+ throw new Unsupported("iOS has no back key; tap the app's back button (for example its navigation bar button)");
26
+ default:
27
+ throw new Unsupported(`'${key}' is not an ios key; use ${IOS_KEYS.join(', ')}`);
28
+ }
29
+ }
30
+ export const IOS_ACTIONS = [
31
+ 'launch',
32
+ 'terminate',
33
+ 'reset',
34
+ 'deep_link',
35
+ 'tap',
36
+ 'double_tap',
37
+ 'long_press',
38
+ 'type',
39
+ 'clear',
40
+ 'press',
41
+ 'swipe',
42
+ 'scroll_to',
43
+ 'set_orientation',
44
+ 'accept_alert',
45
+ 'dismiss_alert',
46
+ 'wait',
47
+ 'wait_for',
48
+ 'screenshot',
49
+ 'expect_visible',
50
+ 'expect_hidden',
51
+ 'expect_text',
52
+ ];
53
+ function requireTarget(action) {
54
+ if (!action.target)
55
+ throw new Error(`'${action.type}' needs a target`);
56
+ return action.target;
57
+ }
58
+ async function element(ctx, target, timeoutMs) {
59
+ const found = await iosLocate(ctx.client, target, timeoutMs, ctx.render);
60
+ if (!found)
61
+ throw new NotFound(`${describeIosTarget(target)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
62
+ ctx.onLocated?.(found);
63
+ return found;
64
+ }
65
+ async function visibleCount(ctx, target) {
66
+ const ids = await iosFindAll(ctx.client, { ...target, fallbacks: undefined }, ctx.render);
67
+ const pool = target.nth === undefined ? ids : ids.slice(target.nth, target.nth + 1);
68
+ let count = 0;
69
+ for (const id of pool) {
70
+ if (await ctx.client.isElementDisplayed(id).catch(() => false))
71
+ count += 1;
72
+ }
73
+ return count;
74
+ }
75
+ /** The text an element shows: its label, else its value (XCUITest's element text is one of them). */
76
+ export async function iosElementText(client, elementId) {
77
+ const label = await client.getElementAttribute(elementId, 'label').catch(() => null);
78
+ if (label?.trim())
79
+ return label.trim();
80
+ const value = await client.getElementAttribute(elementId, 'value').catch(() => null);
81
+ if (value?.trim())
82
+ return value.trim();
83
+ return ((await client.getElementText(elementId).catch(() => '')) ?? '').trim();
84
+ }
85
+ function checkDirection(direction, fallback) {
86
+ const value = (direction ?? fallback);
87
+ if (!['up', 'down', 'left', 'right'].includes(value))
88
+ throw new Error(`direction must be up, down, left or right, not '${direction}'`);
89
+ return value;
90
+ }
91
+ /** Start and end points of a swipe of `distance` (0.1–1) across `area`, the finger moving in `direction`. */
92
+ export function swipePoints(area, direction, distance) {
93
+ const cx = area.left + area.width / 2;
94
+ const cy = area.top + area.height / 2;
95
+ const dx = (area.width * distance) / 2;
96
+ const dy = (area.height * distance) / 2;
97
+ const points = {
98
+ up: { fromX: cx, fromY: cy + dy, toX: cx, toY: cy - dy },
99
+ down: { fromX: cx, fromY: cy - dy, toX: cx, toY: cy + dy },
100
+ left: { fromX: cx + dx, fromY: cy, toX: cx - dx, toY: cy },
101
+ right: { fromX: cx - dx, fromY: cy, toX: cx + dx, toY: cy },
102
+ }[direction];
103
+ return { fromX: Math.round(points.fromX), fromY: Math.round(points.fromY), toX: Math.round(points.toX), toY: Math.round(points.toY) };
104
+ }
105
+ async function pressKey(ctx, key) {
106
+ const action = iosKeyAction(key);
107
+ if (action.kind === 'button') {
108
+ try {
109
+ await mobile(ctx.client, 'pressButton', { name: action.name });
110
+ }
111
+ catch (error) {
112
+ throw new Error(`the ${key} button could not be pressed: ${webdriverMessage(error)}`);
113
+ }
114
+ return;
115
+ }
116
+ if (!ctx.client.getActiveElement)
117
+ throw new Unsupported(`pressing ${key} is not available with this driver`);
118
+ let active;
119
+ try {
120
+ active = elementIdOf(await ctx.client.getActiveElement());
121
+ }
122
+ catch {
123
+ throw new Error(`nothing has keyboard focus to press ${key} in; tap a text field first`);
124
+ }
125
+ await ctx.client.elementSendKeys(active, action.text);
126
+ }
127
+ /** Performs one ios DSL action (or a session-only `home`). */
128
+ export async function runIosAction(ctx, action) {
129
+ const timeout = action.timeout_ms ?? ctx.defaultTimeoutMs;
130
+ const { client, bundleId } = ctx;
131
+ switch (action.type) {
132
+ case 'launch':
133
+ if (action.activity)
134
+ throw new Unsupported('launch activities are an android feature; iOS launches the app by its bundle id');
135
+ await mobile(client, 'launchApp', { bundleId });
136
+ return {};
137
+ case 'terminate':
138
+ await mobile(client, 'terminateApp', { bundleId });
139
+ return {};
140
+ case 'reset':
141
+ await mobile(client, 'terminateApp', { bundleId });
142
+ if (!ctx.resetApp)
143
+ throw new Unsupported('reset is not available for this app');
144
+ await ctx.resetApp();
145
+ await mobile(client, 'launchApp', { bundleId });
146
+ return {};
147
+ case 'deep_link': {
148
+ const url = checkDeepLink(ctx.render(action.url ?? ''));
149
+ try {
150
+ await mobile(client, 'deepLink', { url, bundleId });
151
+ }
152
+ catch (error) {
153
+ throw new Error(`the deep link could not be opened: ${webdriverMessage(error)}`);
154
+ }
155
+ return {};
156
+ }
157
+ case 'tap': {
158
+ const found = await element(ctx, requireTarget(action), timeout);
159
+ await client.elementClick(found.elementId);
160
+ return {};
161
+ }
162
+ case 'double_tap': {
163
+ const found = await element(ctx, requireTarget(action), timeout);
164
+ await mobile(client, 'doubleTap', { elementId: found.elementId });
165
+ return {};
166
+ }
167
+ case 'long_press': {
168
+ const ms = action.ms ?? 1_000;
169
+ if (ms < 300 || ms > 10_000)
170
+ throw new Error('long_press ms must be between 300 and 10000');
171
+ const found = await element(ctx, requireTarget(action), timeout);
172
+ await mobile(client, 'touchAndHold', { elementId: found.elementId, duration: ms / 1_000 });
173
+ return {};
174
+ }
175
+ case 'type': {
176
+ const found = await element(ctx, requireTarget(action), timeout);
177
+ if (action.clear)
178
+ await client.elementClear(found.elementId);
179
+ await client.elementSendKeys(found.elementId, ctx.render(action.value ?? ''));
180
+ return {};
181
+ }
182
+ case 'clear': {
183
+ const found = await element(ctx, requireTarget(action), timeout);
184
+ await client.elementClear(found.elementId);
185
+ return {};
186
+ }
187
+ case 'press':
188
+ await pressKey(ctx, action.key ?? '');
189
+ return {};
190
+ case 'home':
191
+ await pressKey(ctx, 'home');
192
+ return {};
193
+ case 'back':
194
+ iosKeyAction('back');
195
+ return {};
196
+ case 'hide_keyboard':
197
+ throw new Unsupported("'hide_keyboard' is not an ios action; use press {key: return}");
198
+ case 'swipe': {
199
+ const direction = checkDirection(action.direction, 'up');
200
+ const distance = swipeDistance(action.distance);
201
+ let area;
202
+ if (action.target) {
203
+ const found = await element(ctx, action.target, timeout);
204
+ const rect = await client.getElementRect(found.elementId);
205
+ area = { left: rect.x, top: rect.y, width: rect.width, height: rect.height };
206
+ }
207
+ else {
208
+ area = gestureArea(await client.getWindowRect());
209
+ }
210
+ await mobile(client, 'dragFromToWithVelocity', { ...swipePoints(area, direction, distance), pressDuration: 0.05, holdDuration: 0.05, velocity: 2_000 });
211
+ return {};
212
+ }
213
+ case 'scroll_to': {
214
+ const target = requireTarget(action);
215
+ const direction = checkDirection(action.direction, 'down');
216
+ const maxSwipes = action.max_swipes ?? 10;
217
+ if (!Number.isInteger(maxSwipes) || maxSwipes < 1 || maxSwipes > 20)
218
+ throw new Error('max_swipes must be 1-20');
219
+ // Scrolling down reveals what is below: the finger moves up.
220
+ const finger = { down: 'up', up: 'down', left: 'right', right: 'left' }[direction];
221
+ for (let swipe = 0;; swipe += 1) {
222
+ const found = await iosLocate(client, target, 500, ctx.render);
223
+ if (found && (await client.isElementDisplayed(found.elementId).catch(() => false))) {
224
+ ctx.onLocated?.(found);
225
+ return {};
226
+ }
227
+ if (swipe >= maxSwipes)
228
+ break;
229
+ const area = gestureArea(await client.getWindowRect());
230
+ await mobile(client, 'dragFromToWithVelocity', { ...swipePoints(area, finger, 0.6), pressDuration: 0.05, holdDuration: 0.05, velocity: 1_500 });
231
+ }
232
+ throw new Error(`${describeIosTarget(target)} did not appear after scrolling ${direction} ${maxSwipes} times`);
233
+ }
234
+ case 'set_orientation':
235
+ await client.setOrientation(orientationValue(action.value));
236
+ return {};
237
+ case 'accept_alert':
238
+ case 'dismiss_alert': {
239
+ const deadline = Date.now() + timeout;
240
+ for (;;) {
241
+ try {
242
+ if (action.type === 'accept_alert')
243
+ await client.acceptAlert();
244
+ else
245
+ await client.dismissAlert();
246
+ return {};
247
+ }
248
+ catch (error) {
249
+ if (Date.now() >= deadline)
250
+ throw new Error(`no alert was showing: ${webdriverMessage(error)}`);
251
+ await sleep(300);
252
+ }
253
+ }
254
+ }
255
+ case 'wait':
256
+ await sleep(Math.min(Math.max(action.ms ?? 0, 0), 30_000));
257
+ return {};
258
+ case 'screenshot':
259
+ return { screenshot: Buffer.from(await client.takeScreenshot(), 'base64') };
260
+ case 'wait_for':
261
+ case 'expect_visible': {
262
+ const target = requireTarget(action);
263
+ await expectWithin(timeout, async () => {
264
+ const found = await iosLocate(client, target, 0, ctx.render);
265
+ if (!found)
266
+ return false;
267
+ const shown = await client.isElementDisplayed(found.elementId).catch(() => false);
268
+ if (shown)
269
+ ctx.onLocated?.(found);
270
+ return shown;
271
+ }, `${describeIosTarget(target)} to be visible`);
272
+ return {};
273
+ }
274
+ case 'expect_hidden': {
275
+ const target = requireTarget(action);
276
+ await expectWithin(timeout, async () => (await visibleCount(ctx, target)) === 0, `${describeIosTarget(target)} to be hidden`);
277
+ return {};
278
+ }
279
+ case 'expect_text': {
280
+ const target = requireTarget(action);
281
+ const expected = ctx.render(action.value ?? '');
282
+ let actual = '';
283
+ await expectWithin(timeout, async () => {
284
+ const found = await iosLocate(client, target, 0, ctx.render);
285
+ if (!found) {
286
+ actual = '(not found)';
287
+ return false;
288
+ }
289
+ actual = await iosElementText(client, found.elementId);
290
+ return matches(actual, expected, action.match);
291
+ }, () => `text of ${describeIosTarget(target)} to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
292
+ return {};
293
+ }
294
+ case 'goto':
295
+ throw new Unsupported(`'goto' is not an ios action; use deep_link`);
296
+ default:
297
+ throw new Unsupported(`'${action.type}' is not an ios action`);
298
+ }
299
+ }
@@ -0,0 +1,121 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { candidates, elementIdOf } from './locate.js';
3
+ /** iOS locator keys (plan §3); exactly one is set on a target. */
4
+ export const IOS_LOCATORS = ['id', 'label', 'text', 'test_id', 'type', 'predicate', 'class_chain', 'xpath'];
5
+ /** DSL `type` values → XCUIElementType class names (plan §3). */
6
+ export const IOS_TYPES = {
7
+ button: 'XCUIElementTypeButton',
8
+ textfield: 'XCUIElementTypeTextField',
9
+ securetextfield: 'XCUIElementTypeSecureTextField',
10
+ statictext: 'XCUIElementTypeStaticText',
11
+ cell: 'XCUIElementTypeCell',
12
+ switch: 'XCUIElementTypeSwitch',
13
+ image: 'XCUIElementTypeImage',
14
+ other: 'XCUIElementTypeOther',
15
+ };
16
+ /** `button` → `XCUIElementTypeButton`; a full `XCUIElementType…` name is accepted as is. */
17
+ export function iosElementType(value) {
18
+ const lower = value.trim().toLowerCase();
19
+ const mapped = IOS_TYPES[lower];
20
+ if (mapped)
21
+ return mapped;
22
+ if (/^XCUIElementType[A-Za-z]+$/.test(value.trim()))
23
+ return value.trim();
24
+ throw new Error(`type '${value}' is not an iOS element type; use ${Object.keys(IOS_TYPES).join(', ')}`);
25
+ }
26
+ /** Quotes a string literal for an NSPredicate (double quotes, backslash escapes). */
27
+ export function predicateString(value) {
28
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
29
+ }
30
+ /**
31
+ * Maps one ios target (without fallbacks/nth) to the selectors to try, in order.
32
+ * `render` resolves `{{placeholders}}` in the value first.
33
+ *
34
+ * - `id` / `test_id` (React Native / Expo `testID`): the accessibility identifier.
35
+ * - `label`: the accessibility label, exact unless `exact: false` (then contains).
36
+ * - `text`: label or value containing the text, exact when `exact: true`.
37
+ * - `type`: an element type (`button`, `textfield`, …).
38
+ * - `predicate`, `class_chain`, `xpath`: used as they are.
39
+ */
40
+ export function iosSelectors(target, render = (t) => t) {
41
+ if (target.id !== undefined)
42
+ return [{ using: 'accessibility id', value: render(target.id) }];
43
+ if (target.test_id !== undefined)
44
+ return [{ using: 'accessibility id', value: render(target.test_id) }];
45
+ if (target.label !== undefined) {
46
+ const label = predicateString(render(target.label));
47
+ return [{ using: '-ios predicate string', value: target.exact === false ? `label CONTAINS ${label}` : `label == ${label}` }];
48
+ }
49
+ if (target.text !== undefined) {
50
+ const text = predicateString(render(target.text));
51
+ const op = target.exact === true ? '==' : 'CONTAINS';
52
+ return [{ using: '-ios predicate string', value: `label ${op} ${text} OR value ${op} ${text}` }];
53
+ }
54
+ if (target.type !== undefined)
55
+ return [{ using: 'class name', value: iosElementType(render(target.type)) }];
56
+ if (target.predicate !== undefined)
57
+ return [{ using: '-ios predicate string', value: render(target.predicate).trim() }];
58
+ if (target.class_chain !== undefined)
59
+ return [{ using: '-ios class chain', value: render(target.class_chain).trim() }];
60
+ if (target.xpath !== undefined)
61
+ return [{ using: 'xpath', value: render(target.xpath) }];
62
+ const named = Object.keys(target).filter((key) => !['exact', 'nth', 'fallbacks', 'locate'].includes(key));
63
+ throw new Error(named.length > 0 ? `'${named[0]}' is not an ios locator; use one of ${IOS_LOCATORS.join(', ')}` : 'the target names no locator');
64
+ }
65
+ export function describeIosTarget(target) {
66
+ if (target.id !== undefined)
67
+ return `id "${target.id}"`;
68
+ if (target.test_id !== undefined)
69
+ return `test id "${target.test_id}"`;
70
+ if (target.label !== undefined)
71
+ return `element labelled "${target.label}"`;
72
+ if (target.text !== undefined)
73
+ return `text "${target.text}"`;
74
+ if (target.type !== undefined)
75
+ return `${target.type}`;
76
+ if (target.predicate !== undefined)
77
+ return `predicate ${target.predicate}`;
78
+ if (target.class_chain !== undefined)
79
+ return `class chain ${target.class_chain}`;
80
+ if (target.xpath !== undefined)
81
+ return `xpath ${target.xpath}`;
82
+ return 'element';
83
+ }
84
+ /** Elements matching one target (no fallbacks), in document order. */
85
+ export async function iosFindAll(client, target, render) {
86
+ for (const selector of iosSelectors(target, render)) {
87
+ const found = await client.findElements(selector.using, selector.value).catch((error) => {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ // A malformed predicate, class chain or xpath is the script's problem, not a missing element.
90
+ if (/invalid selector|InvalidSelector|Unable to parse|parse the format|is not a valid/i.test(message) && !/no such element/i.test(message)) {
91
+ throw error;
92
+ }
93
+ return [];
94
+ });
95
+ if (found.length > 0)
96
+ return found.map(elementIdOf);
97
+ }
98
+ return [];
99
+ }
100
+ /** One pass over the target and its fallbacks; null when nothing matches (at `nth`). */
101
+ export async function iosLocateOnce(client, target, render) {
102
+ for (const candidate of candidates(target)) {
103
+ const ids = await iosFindAll(client, candidate, render);
104
+ const nth = candidate.nth ?? 0;
105
+ if (ids.length > nth)
106
+ return { elementId: ids[nth], target: candidate, count: ids.length };
107
+ }
108
+ return null;
109
+ }
110
+ /** Polls until the target (or a fallback) matches or `timeoutMs` passes. */
111
+ export async function iosLocate(client, target, timeoutMs, render, pollMs = 300) {
112
+ const deadline = Date.now() + timeoutMs;
113
+ for (;;) {
114
+ const found = await iosLocateOnce(client, target, render);
115
+ if (found)
116
+ return found;
117
+ if (Date.now() >= deadline)
118
+ return null;
119
+ await sleep(pollMs);
120
+ }
121
+ }