@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.
- package/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3C capabilities for a UiAutomator2 session on one device. The app is installed and cleared
|
|
3
|
+
* by the runner (`noReset`, no `app` capability), so Appium never reinstalls or resets it itself.
|
|
4
|
+
*/
|
|
5
|
+
export function uiautomator2Capabilities(options) {
|
|
6
|
+
return {
|
|
7
|
+
platformName: 'Android',
|
|
8
|
+
'appium:automationName': 'UiAutomator2',
|
|
9
|
+
'appium:udid': options.serial,
|
|
10
|
+
'appium:systemPort': options.systemPort,
|
|
11
|
+
'appium:mjpegServerPort': options.mjpegServerPort,
|
|
12
|
+
'appium:appPackage': options.appPackage,
|
|
13
|
+
...(options.appActivity ? { 'appium:appActivity': options.appActivity } : {}),
|
|
14
|
+
'appium:autoLaunch': false,
|
|
15
|
+
'appium:noReset': true,
|
|
16
|
+
'appium:dontStopAppOnReset': true,
|
|
17
|
+
'appium:autoGrantPermissions': true,
|
|
18
|
+
'appium:disableWindowAnimation': true,
|
|
19
|
+
'appium:skipUnlock': false,
|
|
20
|
+
'appium:newCommandTimeout': options.newCommandTimeoutSecs ?? 300,
|
|
21
|
+
'appium:uiautomator2ServerLaunchTimeout': 120_000,
|
|
22
|
+
'appium:uiautomator2ServerInstallTimeout': 120_000,
|
|
23
|
+
'appium:adbExecTimeout': 60_000,
|
|
24
|
+
'appium:androidInstallTimeout': 180_000,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* W3C capabilities for an XCUITest session on one simulator or device. No `app` or `bundleId`:
|
|
29
|
+
* the runner installs and launches the app itself, so Appium never resets it on its own.
|
|
30
|
+
*/
|
|
31
|
+
export function xcuitestCapabilities(options) {
|
|
32
|
+
return {
|
|
33
|
+
platformName: 'iOS',
|
|
34
|
+
'appium:automationName': 'XCUITest',
|
|
35
|
+
'appium:udid': options.udid,
|
|
36
|
+
...(options.platformVersion ? { 'appium:platformVersion': options.platformVersion } : {}),
|
|
37
|
+
'appium:wdaLocalPort': options.wdaLocalPort,
|
|
38
|
+
'appium:mjpegServerPort': options.mjpegServerPort,
|
|
39
|
+
'appium:derivedDataPath': options.derivedDataPath,
|
|
40
|
+
'appium:usePrebuiltWDA': options.usePrebuiltWDA,
|
|
41
|
+
'appium:noReset': true,
|
|
42
|
+
'appium:autoLaunch': false,
|
|
43
|
+
'appium:newCommandTimeout': options.newCommandTimeoutSecs ?? 300,
|
|
44
|
+
'appium:wdaLaunchTimeout': 240_000,
|
|
45
|
+
'appium:wdaConnectionTimeout': 240_000,
|
|
46
|
+
'appium:wdaStartupRetries': 2,
|
|
47
|
+
'appium:simulatorStartupTimeout': options.simulatorStartupTimeoutMs ?? 300_000,
|
|
48
|
+
'appium:appPushTimeout': 300_000,
|
|
49
|
+
'appium:reduceMotion': true,
|
|
50
|
+
'appium:connectHardwareKeyboard': false,
|
|
51
|
+
'appium:showXcodeLog': false,
|
|
52
|
+
...(options.simulator
|
|
53
|
+
? { 'appium:isHeadless': options.isHeadless ?? true }
|
|
54
|
+
: {
|
|
55
|
+
...(options.teamId ? { 'appium:xcodeOrgId': options.teamId } : {}),
|
|
56
|
+
'appium:xcodeSigningId': options.signingId || 'Apple Development',
|
|
57
|
+
...(options.wdaBundleId ? { 'appium:updatedWDABundleId': options.wdaBundleId } : {}),
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Opens a WebDriver session against a local Appium server. */
|
|
62
|
+
export async function openSession(serverUrl, capabilities, connectionRetryTimeoutMs = 240_000) {
|
|
63
|
+
const url = new URL(serverUrl);
|
|
64
|
+
// Loaded lazily: web-only runners never pay for the WebDriver client.
|
|
65
|
+
const { default: WebDriver } = await import('webdriver');
|
|
66
|
+
await quietWdioLogs();
|
|
67
|
+
const client = await WebDriver.newSession({
|
|
68
|
+
protocol: url.protocol.replace(':', ''),
|
|
69
|
+
hostname: url.hostname,
|
|
70
|
+
port: Number(url.port),
|
|
71
|
+
path: '/',
|
|
72
|
+
logLevel: 'silent',
|
|
73
|
+
connectionRetryTimeout: connectionRetryTimeoutMs,
|
|
74
|
+
connectionRetryCount: 0,
|
|
75
|
+
capabilities: { alwaysMatch: capabilities, firstMatch: [{}] },
|
|
76
|
+
});
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
let quieted = false;
|
|
80
|
+
/** The WebDriver client logs to stdout through @wdio/logger (a dependency of `webdriver`); keep the runner's output clean. */
|
|
81
|
+
async function quietWdioLogs() {
|
|
82
|
+
if (quieted)
|
|
83
|
+
return;
|
|
84
|
+
quieted = true;
|
|
85
|
+
try {
|
|
86
|
+
const specifier = '@wdio/logger';
|
|
87
|
+
const { default: logger } = (await import(specifier));
|
|
88
|
+
for (const name of ['webdriver', '@wdio/utils', '@wdio/config'])
|
|
89
|
+
logger.setLevel(name, 'silent');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// not fatal: only log noise
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Runs an Appium `mobile:` extension command. */
|
|
96
|
+
export function mobile(client, command, args = {}) {
|
|
97
|
+
if (!/^[a-zA-Z]+$/.test(command))
|
|
98
|
+
throw new Error(`invalid mobile command '${command}'`);
|
|
99
|
+
return client.executeScript(`mobile: ${command}`, [args]);
|
|
100
|
+
}
|
|
101
|
+
/** WebDriver errors carry the Appium message after a prefix; keep the useful first line. */
|
|
102
|
+
export function webdriverMessage(error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
return message.split('\n')[0].replace(/^WebDriverError:\s*/, '').slice(0, 500);
|
|
105
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { clampDepth, clampMaxChars, clipText, renderSnapshotText, SNAPSHOT_LIMITS, } from '../snapshot/format.js';
|
|
2
|
+
import { MAC_ROLE_BY_TYPE, WINDOWS_CONTROL_TYPES } from './native-locate.js';
|
|
3
|
+
import { parsePageSource, suggestionFor } from './xml-tree.js';
|
|
4
|
+
function num(value) {
|
|
5
|
+
if (value === undefined || value === '')
|
|
6
|
+
return null;
|
|
7
|
+
const n = Number(value);
|
|
8
|
+
return Number.isFinite(n) ? Math.round(n) : null;
|
|
9
|
+
}
|
|
10
|
+
function boundsOf(element) {
|
|
11
|
+
const x = num(element.attrs.x);
|
|
12
|
+
const y = num(element.attrs.y);
|
|
13
|
+
const width = num(element.attrs.width);
|
|
14
|
+
const height = num(element.attrs.height);
|
|
15
|
+
if (x === null || y === null || width === null || height === null)
|
|
16
|
+
return null;
|
|
17
|
+
return { x, y, width, height };
|
|
18
|
+
}
|
|
19
|
+
function add(map, key, element) {
|
|
20
|
+
if (!key)
|
|
21
|
+
return;
|
|
22
|
+
const list = map.get(key);
|
|
23
|
+
if (list) {
|
|
24
|
+
if (list.at(-1) !== element)
|
|
25
|
+
list.push(element);
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
map.set(key, [element]);
|
|
29
|
+
}
|
|
30
|
+
function withNth(target, list, element) {
|
|
31
|
+
if (!list || list.length <= 1)
|
|
32
|
+
return target;
|
|
33
|
+
return { ...target, nth: list.indexOf(element) };
|
|
34
|
+
}
|
|
35
|
+
function build(tree, rules, options, rootName) {
|
|
36
|
+
const depthLimit = clampDepth(options.depth);
|
|
37
|
+
const maxNodes = Math.min(options.maxNodes ?? SNAPSHOT_LIMITS.nodes, SNAPSHOT_LIMITS.nodes);
|
|
38
|
+
let next = options.firstRef ?? 1;
|
|
39
|
+
let emitted = 0;
|
|
40
|
+
let truncated = false;
|
|
41
|
+
const refs = {};
|
|
42
|
+
const rootBounds = boundsOf(tree.root);
|
|
43
|
+
const root = { ref: 'e0', role: 'screen', ...(rootName ? { name: clipText(rootName) } : {}) };
|
|
44
|
+
if (rootBounds)
|
|
45
|
+
root.bounds = rootBounds;
|
|
46
|
+
const convert = (element, depth, parent, context) => {
|
|
47
|
+
const bounds = boundsOf(element);
|
|
48
|
+
const hidden = rules.hidden(element, bounds);
|
|
49
|
+
if (hidden && !options.includeHidden)
|
|
50
|
+
return;
|
|
51
|
+
const role = rules.role(element);
|
|
52
|
+
const name = rules.name(element);
|
|
53
|
+
// A text inside a control that says the same thing adds nothing.
|
|
54
|
+
const redundant = role === 'text' && name !== '' && name === context && !rules.id(element);
|
|
55
|
+
if (rules.layoutOnly(element) || redundant) {
|
|
56
|
+
for (const child of element.children)
|
|
57
|
+
convert(child, depth, parent, context);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (emitted >= maxNodes || depth > depthLimit) {
|
|
61
|
+
truncated = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
emitted += 1;
|
|
65
|
+
const ref = `e${next}`;
|
|
66
|
+
next += 1;
|
|
67
|
+
const node = { ref, role };
|
|
68
|
+
if (name)
|
|
69
|
+
node.name = clipText(name);
|
|
70
|
+
const value = rules.value(element, name);
|
|
71
|
+
if (value)
|
|
72
|
+
node.text = clipText(value);
|
|
73
|
+
const id = rules.id(element);
|
|
74
|
+
if (id)
|
|
75
|
+
node.ids = { accessibility_id: id };
|
|
76
|
+
if (bounds)
|
|
77
|
+
node.bounds = bounds;
|
|
78
|
+
const state = rules.state(element);
|
|
79
|
+
if (hidden)
|
|
80
|
+
state.push('hidden');
|
|
81
|
+
if (state.length > 0)
|
|
82
|
+
node.state = state;
|
|
83
|
+
const { suggest, alternatives, fragile } = suggestionFor(rules.candidates(element));
|
|
84
|
+
refs[ref] = { locate: { ...rules.locate(element), ...(bounds ? { bounds } : {}) }, suggest, alternatives, fragile, bounds: bounds ?? null };
|
|
85
|
+
(parent.children ??= []).push(node);
|
|
86
|
+
for (const child of element.children)
|
|
87
|
+
convert(child, depth + 1, node, name || context);
|
|
88
|
+
};
|
|
89
|
+
for (const child of tree.root.children)
|
|
90
|
+
convert(child, 1, root, '');
|
|
91
|
+
const rendered = renderSnapshotText(root, clampMaxChars(options.maxChars));
|
|
92
|
+
for (const ref of Object.keys(refs)) {
|
|
93
|
+
if (!rendered.refs.has(ref))
|
|
94
|
+
delete refs[ref];
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
root,
|
|
98
|
+
text: rendered.text,
|
|
99
|
+
refs,
|
|
100
|
+
element_count: emitted,
|
|
101
|
+
truncated: truncated || rendered.truncated,
|
|
102
|
+
width: rootBounds?.width ?? null,
|
|
103
|
+
height: rootBounds?.height ?? null,
|
|
104
|
+
rotation: null,
|
|
105
|
+
appName: rootName,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
// macOS
|
|
110
|
+
// ---------------------------------------------------------------------------------------------
|
|
111
|
+
const MAC_PREFIX = 'XCUIElementType';
|
|
112
|
+
export function macTypeOf(element) {
|
|
113
|
+
return element.tag.startsWith(MAC_PREFIX) ? element.tag.slice(MAC_PREFIX.length) : element.tag;
|
|
114
|
+
}
|
|
115
|
+
/** XCUIElementType (without prefix) → snapshot role. Types not listed are `group`. */
|
|
116
|
+
export const MAC_SNAPSHOT_ROLES = {
|
|
117
|
+
Button: 'button',
|
|
118
|
+
ToolbarButton: 'button',
|
|
119
|
+
MenuButton: 'button',
|
|
120
|
+
DisclosureTriangle: 'button',
|
|
121
|
+
PopUpButton: 'popupbutton',
|
|
122
|
+
ComboBox: 'combobox',
|
|
123
|
+
CheckBox: 'checkbox',
|
|
124
|
+
Switch: 'switch',
|
|
125
|
+
Toggle: 'switch',
|
|
126
|
+
RadioButton: 'radio',
|
|
127
|
+
RadioGroup: 'radiogroup',
|
|
128
|
+
TextField: 'textfield',
|
|
129
|
+
SecureTextField: 'textfield',
|
|
130
|
+
SearchField: 'search',
|
|
131
|
+
TextView: 'textfield',
|
|
132
|
+
StaticText: 'text',
|
|
133
|
+
Link: 'link',
|
|
134
|
+
Image: 'image',
|
|
135
|
+
Icon: 'image',
|
|
136
|
+
Window: 'window',
|
|
137
|
+
Sheet: 'dialog',
|
|
138
|
+
Dialog: 'dialog',
|
|
139
|
+
Alert: 'alert',
|
|
140
|
+
Popover: 'dialog',
|
|
141
|
+
MenuBar: 'menubar',
|
|
142
|
+
MenuBarItem: 'menuitem',
|
|
143
|
+
Menu: 'menu',
|
|
144
|
+
MenuItem: 'menuitem',
|
|
145
|
+
Toolbar: 'toolbar',
|
|
146
|
+
TabGroup: 'tablist',
|
|
147
|
+
Tab: 'tab',
|
|
148
|
+
SegmentedControl: 'tablist',
|
|
149
|
+
Table: 'table',
|
|
150
|
+
Outline: 'tree',
|
|
151
|
+
TableRow: 'row',
|
|
152
|
+
OutlineRow: 'row',
|
|
153
|
+
Cell: 'cell',
|
|
154
|
+
CollectionView: 'list',
|
|
155
|
+
Browser: 'list',
|
|
156
|
+
Slider: 'slider',
|
|
157
|
+
Stepper: 'stepper',
|
|
158
|
+
IncrementArrow: 'button',
|
|
159
|
+
DecrementArrow: 'button',
|
|
160
|
+
ProgressIndicator: 'progressbar',
|
|
161
|
+
ActivityIndicator: 'progressbar',
|
|
162
|
+
LevelIndicator: 'progressbar',
|
|
163
|
+
DatePicker: 'picker',
|
|
164
|
+
ColorWell: 'button',
|
|
165
|
+
ScrollView: 'scrollview',
|
|
166
|
+
ScrollBar: 'scrollbar',
|
|
167
|
+
WebView: 'webview',
|
|
168
|
+
SplitGroup: 'group',
|
|
169
|
+
Splitter: 'separator',
|
|
170
|
+
Group: 'group',
|
|
171
|
+
Other: 'group',
|
|
172
|
+
};
|
|
173
|
+
export function macRoleFor(element) {
|
|
174
|
+
return MAC_SNAPSHOT_ROLES[macTypeOf(element)] ?? 'group';
|
|
175
|
+
}
|
|
176
|
+
const MAC_TOGGLES = ['CheckBox', 'Switch', 'Toggle', 'RadioButton'];
|
|
177
|
+
const MAC_TEXT_INPUTS = ['TextField', 'SecureTextField', 'SearchField', 'TextView', 'ComboBox'];
|
|
178
|
+
function macName(element) {
|
|
179
|
+
const a = element.attrs;
|
|
180
|
+
if (a.title)
|
|
181
|
+
return a.title;
|
|
182
|
+
if (a.label)
|
|
183
|
+
return a.label;
|
|
184
|
+
const type = macTypeOf(element);
|
|
185
|
+
if (type === 'StaticText' && a.value)
|
|
186
|
+
return a.value;
|
|
187
|
+
if (MAC_TEXT_INPUTS.includes(type) && a.placeholderValue)
|
|
188
|
+
return a.placeholderValue;
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
function macValue(element, name) {
|
|
192
|
+
const type = macTypeOf(element);
|
|
193
|
+
const value = element.attrs.value ?? '';
|
|
194
|
+
if (!value || value === name || type === 'SecureTextField' || MAC_TOGGLES.includes(type))
|
|
195
|
+
return '';
|
|
196
|
+
if (['Slider', 'ProgressIndicator', 'LevelIndicator', 'ScrollBar', 'ValueIndicator', 'Splitter'].includes(type))
|
|
197
|
+
return '';
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function macState(element) {
|
|
201
|
+
const a = element.attrs;
|
|
202
|
+
const type = macTypeOf(element);
|
|
203
|
+
const state = [];
|
|
204
|
+
if (MAC_TOGGLES.includes(type))
|
|
205
|
+
state.push(a.value === '1' || a.value === 'true' ? 'checked' : 'unchecked');
|
|
206
|
+
if (a.enabled === 'false')
|
|
207
|
+
state.push('disabled');
|
|
208
|
+
if (a.selected === 'true')
|
|
209
|
+
state.push('selected');
|
|
210
|
+
if (type === 'SecureTextField')
|
|
211
|
+
state.push('password');
|
|
212
|
+
return state;
|
|
213
|
+
}
|
|
214
|
+
function macLayoutOnly(element) {
|
|
215
|
+
const type = macTypeOf(element);
|
|
216
|
+
const a = element.attrs;
|
|
217
|
+
if (!['Group', 'Other', 'SplitGroup', 'ScrollView', 'LayoutArea', 'LayoutItem', 'Matte', 'Unknown'].includes(type))
|
|
218
|
+
return false;
|
|
219
|
+
return !a.identifier && !a.title && !a.label && !a.value;
|
|
220
|
+
}
|
|
221
|
+
const GENERATED_IDENTIFIER = /^_NS:\d+$/;
|
|
222
|
+
/** An XCUIElementType the class chain can name (`**\/XCUIElementTypeButton[3]`, 1-based). */
|
|
223
|
+
function macClassChain(element, position) {
|
|
224
|
+
return `**/${element.tag}[${position + 1}]`;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* macOS page source → snapshot. Zero-sized elements (items of closed menus, collapsed views)
|
|
228
|
+
* are hidden unless `includeHidden`. Every element counts for `nth`: Mac2 queries return
|
|
229
|
+
* hidden elements too.
|
|
230
|
+
*/
|
|
231
|
+
export function macSnapshotFromXml(xml, options = {}) {
|
|
232
|
+
const tree = parsePageSource(xml);
|
|
233
|
+
const identifiers = new Map();
|
|
234
|
+
const titles = new Map();
|
|
235
|
+
const types = new Map();
|
|
236
|
+
for (const element of tree.all) {
|
|
237
|
+
add(identifiers, element.attrs.identifier, element);
|
|
238
|
+
// `title` matches title or label.
|
|
239
|
+
add(titles, element.attrs.title, element);
|
|
240
|
+
if (element.attrs.label !== element.attrs.title)
|
|
241
|
+
add(titles, element.attrs.label, element);
|
|
242
|
+
add(types, element.tag, element);
|
|
243
|
+
}
|
|
244
|
+
const rules = {
|
|
245
|
+
role: macRoleFor,
|
|
246
|
+
name: macName,
|
|
247
|
+
value: macValue,
|
|
248
|
+
id: (element) => element.attrs.identifier ?? '',
|
|
249
|
+
state: macState,
|
|
250
|
+
hidden: (_element, bounds) => bounds !== null && (bounds.width <= 0 || bounds.height <= 0),
|
|
251
|
+
layoutOnly: macLayoutOnly,
|
|
252
|
+
candidates: (element) => {
|
|
253
|
+
const out = [];
|
|
254
|
+
const a = element.attrs;
|
|
255
|
+
// `_NS:34`-style identifiers are generated by Interface Builder and change between builds.
|
|
256
|
+
if (a.identifier && !GENERATED_IDENTIFIER.test(a.identifier) && a.identifier.length <= 2000) {
|
|
257
|
+
const list = identifiers.get(a.identifier);
|
|
258
|
+
out.push({ target: withNth({ identifier: a.identifier }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
259
|
+
}
|
|
260
|
+
const title = a.title || a.label;
|
|
261
|
+
if (title && title.length <= 2000) {
|
|
262
|
+
const list = titles.get(title);
|
|
263
|
+
out.push({ target: withNth({ title }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
264
|
+
}
|
|
265
|
+
const list = types.get(element.tag) ?? [];
|
|
266
|
+
const position = list.indexOf(element);
|
|
267
|
+
const role = MAC_ROLE_BY_TYPE[macTypeOf(element)];
|
|
268
|
+
if (role && position >= 0)
|
|
269
|
+
out.push({ target: list.length > 1 ? { role, nth: position } : { role }, fragile: list.length > 1 });
|
|
270
|
+
if (position >= 0)
|
|
271
|
+
out.push({ target: { class_chain: macClassChain(element, position) }, fragile: true });
|
|
272
|
+
return out;
|
|
273
|
+
},
|
|
274
|
+
locate: (element) => {
|
|
275
|
+
const position = (types.get(element.tag) ?? []).indexOf(element);
|
|
276
|
+
return { class_chain: macClassChain(element, Math.max(0, position)), type: element.tag };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
const app = tree.root;
|
|
280
|
+
const name = app.attrs.title || app.attrs.label || null;
|
|
281
|
+
return build(tree, rules, options, name);
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------------------------
|
|
284
|
+
// Windows
|
|
285
|
+
// ---------------------------------------------------------------------------------------------
|
|
286
|
+
/** UIA control type → snapshot role. Types not listed are `group`. */
|
|
287
|
+
export const WINDOWS_SNAPSHOT_ROLES = {
|
|
288
|
+
Button: 'button',
|
|
289
|
+
SplitButton: 'button',
|
|
290
|
+
Edit: 'textfield',
|
|
291
|
+
Document: 'document',
|
|
292
|
+
Text: 'text',
|
|
293
|
+
CheckBox: 'checkbox',
|
|
294
|
+
RadioButton: 'radio',
|
|
295
|
+
ComboBox: 'combobox',
|
|
296
|
+
List: 'list',
|
|
297
|
+
ListItem: 'listitem',
|
|
298
|
+
Menu: 'menu',
|
|
299
|
+
MenuBar: 'menubar',
|
|
300
|
+
MenuItem: 'menuitem',
|
|
301
|
+
Tab: 'tablist',
|
|
302
|
+
TabItem: 'tab',
|
|
303
|
+
Tree: 'tree',
|
|
304
|
+
TreeItem: 'treeitem',
|
|
305
|
+
Window: 'window',
|
|
306
|
+
TitleBar: 'titlebar',
|
|
307
|
+
Hyperlink: 'link',
|
|
308
|
+
Image: 'image',
|
|
309
|
+
Slider: 'slider',
|
|
310
|
+
Spinner: 'spinbutton',
|
|
311
|
+
ToolBar: 'toolbar',
|
|
312
|
+
StatusBar: 'statusbar',
|
|
313
|
+
ScrollBar: 'scrollbar',
|
|
314
|
+
ProgressBar: 'progressbar',
|
|
315
|
+
DataGrid: 'grid',
|
|
316
|
+
DataItem: 'row',
|
|
317
|
+
Table: 'table',
|
|
318
|
+
Header: 'header',
|
|
319
|
+
HeaderItem: 'columnheader',
|
|
320
|
+
ToolTip: 'tooltip',
|
|
321
|
+
Separator: 'separator',
|
|
322
|
+
Calendar: 'grid',
|
|
323
|
+
AppBar: 'toolbar',
|
|
324
|
+
Pane: 'group',
|
|
325
|
+
Group: 'group',
|
|
326
|
+
Custom: 'group',
|
|
327
|
+
Thumb: 'group',
|
|
328
|
+
};
|
|
329
|
+
export function windowsRoleFor(element) {
|
|
330
|
+
return WINDOWS_SNAPSHOT_ROLES[element.tag] ?? 'group';
|
|
331
|
+
}
|
|
332
|
+
function isTrueish(value) {
|
|
333
|
+
return value?.toLowerCase() === 'true';
|
|
334
|
+
}
|
|
335
|
+
function windowsChecked(element) {
|
|
336
|
+
const a = element.attrs;
|
|
337
|
+
const toggle = a['Toggle.ToggleState'] ?? a.ToggleState;
|
|
338
|
+
if (toggle !== undefined)
|
|
339
|
+
return toggle === '1' || toggle.toLowerCase() === 'on';
|
|
340
|
+
const selected = a['SelectionItem.IsSelected'];
|
|
341
|
+
if (selected !== undefined && element.tag === 'RadioButton')
|
|
342
|
+
return isTrueish(selected);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function windowsState(element) {
|
|
346
|
+
const a = element.attrs;
|
|
347
|
+
const state = [];
|
|
348
|
+
if (element.tag === 'CheckBox' || element.tag === 'RadioButton') {
|
|
349
|
+
const checked = windowsChecked(element);
|
|
350
|
+
if (checked !== null)
|
|
351
|
+
state.push(checked ? 'checked' : 'unchecked');
|
|
352
|
+
}
|
|
353
|
+
if (a.IsEnabled !== undefined && !isTrueish(a.IsEnabled))
|
|
354
|
+
state.push('disabled');
|
|
355
|
+
if (isTrueish(a.HasKeyboardFocus))
|
|
356
|
+
state.push('focused');
|
|
357
|
+
if (isTrueish(a['SelectionItem.IsSelected']) && element.tag !== 'RadioButton')
|
|
358
|
+
state.push('selected');
|
|
359
|
+
if (isTrueish(a.IsPassword))
|
|
360
|
+
state.push('password');
|
|
361
|
+
return state;
|
|
362
|
+
}
|
|
363
|
+
function windowsValue(element, name) {
|
|
364
|
+
const a = element.attrs;
|
|
365
|
+
if (isTrueish(a.IsPassword))
|
|
366
|
+
return '';
|
|
367
|
+
const value = a['Value.Value'] ?? '';
|
|
368
|
+
return value && value !== name ? value : '';
|
|
369
|
+
}
|
|
370
|
+
function windowsLayoutOnly(element) {
|
|
371
|
+
const a = element.attrs;
|
|
372
|
+
if (!['Pane', 'Custom', 'Group', 'Thumb'].includes(element.tag))
|
|
373
|
+
return false;
|
|
374
|
+
return !a.Name && !a.AutomationId && !isTrueish(a.IsKeyboardFocusable);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Windows page source → snapshot. `IsOffscreen="True"` elements are hidden unless
|
|
378
|
+
* `includeHidden`; every element counts for `nth` (WinAppDriver finds off-screen ones too).
|
|
379
|
+
*/
|
|
380
|
+
export function windowsSnapshotFromXml(xml, options = {}) {
|
|
381
|
+
const tree = parsePageSource(xml);
|
|
382
|
+
const automationIds = new Map();
|
|
383
|
+
const names = new Map();
|
|
384
|
+
const types = new Map();
|
|
385
|
+
// The top-level window is part of the searchable tree (`//Window` matches it).
|
|
386
|
+
for (const element of [tree.root, ...tree.all]) {
|
|
387
|
+
add(automationIds, element.attrs.AutomationId, element);
|
|
388
|
+
add(names, element.attrs.Name, element);
|
|
389
|
+
add(types, element.tag, element);
|
|
390
|
+
}
|
|
391
|
+
const rules = {
|
|
392
|
+
role: windowsRoleFor,
|
|
393
|
+
// A password box's Name is its label; UI Automation never exposes the typed value there.
|
|
394
|
+
name: (element) => element.attrs.Name ?? '',
|
|
395
|
+
value: windowsValue,
|
|
396
|
+
id: (element) => element.attrs.AutomationId ?? '',
|
|
397
|
+
state: windowsState,
|
|
398
|
+
hidden: (element, bounds) => isTrueish(element.attrs.IsOffscreen) || (bounds !== null && (bounds.width <= 0 || bounds.height <= 0)),
|
|
399
|
+
layoutOnly: windowsLayoutOnly,
|
|
400
|
+
candidates: (element) => {
|
|
401
|
+
const out = [];
|
|
402
|
+
const a = element.attrs;
|
|
403
|
+
if (a.AutomationId && a.AutomationId.length <= 2000) {
|
|
404
|
+
const list = automationIds.get(a.AutomationId);
|
|
405
|
+
out.push({ target: withNth({ automation_id: a.AutomationId }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
406
|
+
}
|
|
407
|
+
if (a.Name && a.Name.length <= 2000) {
|
|
408
|
+
const list = names.get(a.Name);
|
|
409
|
+
out.push({ target: withNth({ name: a.Name }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
410
|
+
}
|
|
411
|
+
if (WINDOWS_CONTROL_TYPES.includes(element.tag)) {
|
|
412
|
+
const list = types.get(element.tag) ?? [];
|
|
413
|
+
const position = list.indexOf(element);
|
|
414
|
+
if (position >= 0) {
|
|
415
|
+
out.push({ target: list.length > 1 ? { control_type: element.tag, nth: position } : { control_type: element.tag }, fragile: list.length > 1 });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (element.xpath.length <= 500)
|
|
419
|
+
out.push({ target: { xpath: element.xpath }, fragile: true });
|
|
420
|
+
return out;
|
|
421
|
+
},
|
|
422
|
+
locate: (element) => ({ xpath: element.xpath, type: element.tag }),
|
|
423
|
+
};
|
|
424
|
+
return build(tree, rules, options, tree.root.attrs.Name || null);
|
|
425
|
+
}
|