@jterrazz/test 10.0.0 → 11.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/README.md +101 -35
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +399 -0
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +500 -101
- package/dist/index.js +587 -464
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +420 -117
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +420 -117
- package/dist/playwright.adapter.js +3 -127
- package/dist/queue.js +555 -0
- package/package.json +13 -4
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { r as formatElement, t as AmbiguousElementError } from "./ambiguity.js";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
//#region src/core/specification/mobile/ambiguity.ts
|
|
6
|
+
/**
|
|
7
|
+
* The ambiguity refusal, mobile edition — CONVENTIONS W3.
|
|
8
|
+
*
|
|
9
|
+
* A mobile descriptor must designate exactly one element. Acting on "the
|
|
10
|
+
* first match" is the failure mode this module exists to prevent: the spec
|
|
11
|
+
* keeps passing while the visitor taps something else, and nothing ever
|
|
12
|
+
* reports it. So when a descriptor matches several elements the framework
|
|
13
|
+
* refuses, and the refusal carries everything needed to fix it without
|
|
14
|
+
* opening the simulator — the descriptor in the caller's own vocabulary,
|
|
15
|
+
* every candidate, and the concrete rewrites that would resolve it.
|
|
16
|
+
*
|
|
17
|
+
* The element vocabulary is shared with the website facet, so the source
|
|
18
|
+
* rendering (`formatElement`) is too; only the evidence differs — XCUITest
|
|
19
|
+
* types, labels and accessibility identifiers instead of tags and landmarks.
|
|
20
|
+
* Pure string building: the appium integration stays a thin adapter.
|
|
21
|
+
*/
|
|
22
|
+
/** `1. Button "Bookmark" [testId: event-42]` — one evidence line per candidate. */
|
|
23
|
+
function formatMatch(match, index) {
|
|
24
|
+
const label = match.label === void 0 ? "" : ` ${JSON.stringify(match.label)}`;
|
|
25
|
+
const value = match.value === void 0 ? "" : ` value=${JSON.stringify(match.value)}`;
|
|
26
|
+
const identifier = match.identifier === void 0 ? "" : ` [testId: ${match.identifier}]`;
|
|
27
|
+
return ` ${index + 1}. ${match.type}${label}${value}${identifier}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The rewrites worth offering, in the order they should be tried. Scoping is
|
|
31
|
+
* always available; `exact` only when it would actually narrow the set, and
|
|
32
|
+
* the count it would leave is stated so a still-ambiguous suggestion never
|
|
33
|
+
* reads as a fix; distinct identifiers make `testId()` a concrete rewrite
|
|
34
|
+
* rather than a guess.
|
|
35
|
+
*/
|
|
36
|
+
function formatFixes(element, matches) {
|
|
37
|
+
const fixes = [];
|
|
38
|
+
if (!element.scope) fixes.push(`scope it within(testId('…'), ${formatElement(element)}) — a screen has no landmarks; any descriptor works as the scope`);
|
|
39
|
+
if (!element.exact && element.name !== void 0) {
|
|
40
|
+
const remaining = matches.filter((match) => match.label === element.name).length;
|
|
41
|
+
if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatElement({
|
|
42
|
+
exact: true,
|
|
43
|
+
kind: element.kind,
|
|
44
|
+
name: element.name
|
|
45
|
+
})} [leaves ${remaining} of ${matches.length}]`);
|
|
46
|
+
}
|
|
47
|
+
const identifiers = [...new Set(matches.map((match) => match.identifier).filter((identifier) => identifier !== void 0))];
|
|
48
|
+
if (identifiers.length > 1 && element.kind !== "testId") fixes.push(`test id testId(${JSON.stringify(identifiers[0])}) [also here: ${identifiers.slice(1).join(", ")}]`);
|
|
49
|
+
fixes.push("other element a button() or field() may name one thing where this does not");
|
|
50
|
+
return fixes;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the refusal thrown when a descriptor matches more than one element
|
|
54
|
+
* on the screen.
|
|
55
|
+
*
|
|
56
|
+
* @param options.element The descriptor as the caller wrote it.
|
|
57
|
+
* @param options.matches Every candidate, in screen order (already truncated).
|
|
58
|
+
*/
|
|
59
|
+
function describeMobileAmbiguity(options) {
|
|
60
|
+
const { element, matches } = options;
|
|
61
|
+
const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
|
|
62
|
+
return [
|
|
63
|
+
`Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on the screen.`,
|
|
64
|
+
"",
|
|
65
|
+
"A spec must designate exactly one element. Acting on the first match would let",
|
|
66
|
+
"this test keep passing while the visitor taps something else.",
|
|
67
|
+
"",
|
|
68
|
+
"Matched:",
|
|
69
|
+
...matches.map((match, index) => formatMatch(match, index)),
|
|
70
|
+
"",
|
|
71
|
+
"Disambiguate with one of:",
|
|
72
|
+
...fixes,
|
|
73
|
+
"",
|
|
74
|
+
"Docs: docs/12-mobile.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/core/specification/mobile/projection.ts
|
|
79
|
+
const TAG_PATTERN = /<(?<openTag>[A-Za-z][\w.]*)(?<rawAttributes>(?:\s+[\w:.-]+="[^"]*")*)\s*(?<selfClosing>\/?)>|<\/(?<closeTag>[\w.]*)\s*>/g;
|
|
80
|
+
const ATTRIBUTE_PATTERN = /(?<key>[\w:.-]+)="(?<value>[^"]*)"/g;
|
|
81
|
+
const NAMED_ENTITIES = {
|
|
82
|
+
"&": "&",
|
|
83
|
+
"'": "'",
|
|
84
|
+
">": ">",
|
|
85
|
+
"<": "<",
|
|
86
|
+
""": "\""
|
|
87
|
+
};
|
|
88
|
+
/** Decode the XML entities an attribute value can carry. */
|
|
89
|
+
function decodeEntities(value) {
|
|
90
|
+
return value.replace(/&(?:#x(?<hex>[\dA-Fa-f]+)|#(?<decimal>\d+)|(?<named>amp|apos|gt|lt|quot));/g, (entity, hex, decimal) => {
|
|
91
|
+
if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
92
|
+
if (decimal) return String.fromCodePoint(Number(decimal));
|
|
93
|
+
return NAMED_ENTITIES[entity] ?? entity;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Parse the page source into a raw element tree (strict, attribute-only XML). */
|
|
97
|
+
function parsePageSource(xml) {
|
|
98
|
+
const stack = [];
|
|
99
|
+
let root = null;
|
|
100
|
+
for (const match of xml.matchAll(TAG_PATTERN)) {
|
|
101
|
+
const { closeTag, openTag, rawAttributes, selfClosing } = match.groups ?? {};
|
|
102
|
+
if (closeTag !== void 0 || openTag === void 0) {
|
|
103
|
+
stack.pop();
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const attributes = {};
|
|
107
|
+
for (const attribute of (rawAttributes ?? "").matchAll(ATTRIBUTE_PATTERN)) {
|
|
108
|
+
const { key, value } = attribute.groups ?? {};
|
|
109
|
+
if (key !== void 0 && value !== void 0) attributes[key] = decodeEntities(value);
|
|
110
|
+
}
|
|
111
|
+
const node = {
|
|
112
|
+
attributes,
|
|
113
|
+
children: [],
|
|
114
|
+
tag: openTag
|
|
115
|
+
};
|
|
116
|
+
if (stack.length === 0) root = node;
|
|
117
|
+
else stack[stack.length - 1].children.push(node);
|
|
118
|
+
if (!selfClosing) stack.push(node);
|
|
119
|
+
}
|
|
120
|
+
if (!root) throw new Error("projectScreen(): the page source contains no XML element");
|
|
121
|
+
return root;
|
|
122
|
+
}
|
|
123
|
+
/** The substance of a node — what survives projection, or nothing. */
|
|
124
|
+
function substance(node) {
|
|
125
|
+
const label = node.attributes["label"] || void 0;
|
|
126
|
+
const value = node.attributes["value"] || void 0;
|
|
127
|
+
const identifier = node.attributes["name"] || void 0;
|
|
128
|
+
return {
|
|
129
|
+
type: node.tag.replace(/^XCUIElementType/, ""),
|
|
130
|
+
...label === void 0 ? {} : { label },
|
|
131
|
+
...value === void 0 || value === label ? {} : { value },
|
|
132
|
+
...identifier === void 0 || identifier === label ? {} : { identifier }
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function hasSubstance(node) {
|
|
136
|
+
return node.label !== void 0 || node.value !== void 0 || node.identifier !== void 0;
|
|
137
|
+
}
|
|
138
|
+
/** A childless child that repeats its parent is the same accessibility element twice. */
|
|
139
|
+
function repeatsParent(child, parent) {
|
|
140
|
+
return child.children === void 0 && child.type === parent.type && child.label === parent.label && child.value === parent.value && child.identifier === parent.identifier;
|
|
141
|
+
}
|
|
142
|
+
/** Project one raw node: a kept node, or (hoisting) the projections of its children. */
|
|
143
|
+
function projectNode(node) {
|
|
144
|
+
const own = substance(node);
|
|
145
|
+
const children = node.children.flatMap((child) => projectNode(child)).filter((child) => !repeatsParent(child, own));
|
|
146
|
+
if (!hasSubstance(own)) return children;
|
|
147
|
+
return [{
|
|
148
|
+
...own,
|
|
149
|
+
...children.length > 0 ? { children } : {}
|
|
150
|
+
}];
|
|
151
|
+
}
|
|
152
|
+
/** Collect the visible texts in document order, collapsing consecutive repeats. */
|
|
153
|
+
function collectTexts(node, texts) {
|
|
154
|
+
if (node.attributes["visible"] === "true") {
|
|
155
|
+
const text = node.attributes["label"] || node.attributes["value"] || void 0;
|
|
156
|
+
if (text !== void 0 && texts[texts.length - 1] !== text) texts.push(text);
|
|
157
|
+
}
|
|
158
|
+
for (const child of node.children) collectTexts(child, texts);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Project a raw XCUITest page source into the captured screen: the collapsed
|
|
162
|
+
* tree (rooted at the application node, which is always kept) and the
|
|
163
|
+
* visible texts in document order.
|
|
164
|
+
*/
|
|
165
|
+
function projectScreen(xml) {
|
|
166
|
+
let root = parsePageSource(xml);
|
|
167
|
+
if (root.tag === "AppiumAUT" && root.children.length === 1) root = root.children[0];
|
|
168
|
+
const texts = [];
|
|
169
|
+
for (const child of root.children) collectTexts(child, texts);
|
|
170
|
+
const own = substance(root);
|
|
171
|
+
const children = root.children.flatMap((child) => projectNode(child)).filter((child) => !repeatsParent(child, own));
|
|
172
|
+
return {
|
|
173
|
+
texts,
|
|
174
|
+
tree: {
|
|
175
|
+
...own,
|
|
176
|
+
...children.length > 0 ? { children } : {}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/integrations/appium/appium.adapter.ts
|
|
182
|
+
const ACTION_TIMEOUT_MS = 3e4;
|
|
183
|
+
const POLL_INTERVAL_MS = 500;
|
|
184
|
+
/** How many candidates the ambiguity error enumerates before truncating. */
|
|
185
|
+
const MAX_REPORTED_MATCHES = 10;
|
|
186
|
+
/** WebDriverAgent builds once per simulator — the first session is the slow one. */
|
|
187
|
+
const WDA_LAUNCH_TIMEOUT_MS = 24e4;
|
|
188
|
+
const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
189
|
+
/** Escape a name for an NSPredicate single-quoted string literal. */
|
|
190
|
+
function escapePredicate(value) {
|
|
191
|
+
return value.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Translate a user-facing descriptor into an iOS predicate string. Landmarks
|
|
195
|
+
* have no XCUITest analog — the shared vocabulary is wider than a screen,
|
|
196
|
+
* and the boundary is named rather than silently approximated.
|
|
197
|
+
*/
|
|
198
|
+
function compilePredicate(element, options) {
|
|
199
|
+
const name = escapePredicate(element.name ?? "");
|
|
200
|
+
const contains = (attribute) => element.exact ? `${attribute} == '${name}'` : `${attribute} CONTAINS '${name}'`;
|
|
201
|
+
const visible = options?.anyVisibility ? "1 == 1" : "visible == 1";
|
|
202
|
+
switch (element.kind) {
|
|
203
|
+
case "button": return `type == 'XCUIElementTypeButton' AND ${contains("label")} AND ${visible}`;
|
|
204
|
+
case "field": return `type IN {'XCUIElementTypeTextField','XCUIElementTypeSecureTextField'} AND (${contains("label")} OR ${contains("value")}) AND ${visible}`;
|
|
205
|
+
case "testId": return `name == '${name}' AND ${visible}`;
|
|
206
|
+
case "text": return `(${contains("label")} OR ${contains("value")}) AND ${visible}`;
|
|
207
|
+
default: throw new Error(`${formatElement(element)}: landmarks are a website concept — an iOS screen has no '${element.kind}' region. Scope with within(testId('…'), …) instead.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** The outside-in scope chain of a descriptor, ending on the target itself. */
|
|
211
|
+
function scopeChain(element) {
|
|
212
|
+
const chain = [];
|
|
213
|
+
for (let current = element; current; current = current.scope) chain.unshift(current);
|
|
214
|
+
return chain;
|
|
215
|
+
}
|
|
216
|
+
/** Capture one candidate's evidence for the ambiguity refusal. */
|
|
217
|
+
async function captureMatch(element) {
|
|
218
|
+
const [type, label, value, identifier] = await Promise.all([
|
|
219
|
+
element.getAttribute("type"),
|
|
220
|
+
element.getAttribute("label"),
|
|
221
|
+
element.getAttribute("value"),
|
|
222
|
+
element.getAttribute("name")
|
|
223
|
+
]);
|
|
224
|
+
const shortLabel = label ? label.replaceAll(/\s+/g, " ").trim().slice(0, 80) : void 0;
|
|
225
|
+
return {
|
|
226
|
+
type: (type ?? "Unknown").replace(/^XCUIElementType/, ""),
|
|
227
|
+
...shortLabel === void 0 ? {} : { label: shortLabel },
|
|
228
|
+
...value && value !== label ? { value } : {},
|
|
229
|
+
...identifier && identifier !== label ? { identifier } : {}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Device adapter backed by appium's XCUITest driver over webdriverio.
|
|
234
|
+
*
|
|
235
|
+
* ONE driver session per adapter (= per runner = per vitest worker), created
|
|
236
|
+
* lazily on the first `open()` and reused — WebDriverAgent startup is the
|
|
237
|
+
* expensive part, not the app relaunch. Every `open()` terminates and
|
|
238
|
+
* relaunches the app, so each spec starts from a deterministic fresh state.
|
|
239
|
+
*
|
|
240
|
+
* Webdriverio is an optional peer dependency: it is only imported here, and
|
|
241
|
+
* this module is only loaded when a spec calls `.open()`.
|
|
242
|
+
*/
|
|
243
|
+
var AppiumAdapter = class {
|
|
244
|
+
driver = null;
|
|
245
|
+
options;
|
|
246
|
+
constructor(options) {
|
|
247
|
+
this.options = options;
|
|
248
|
+
}
|
|
249
|
+
async close() {
|
|
250
|
+
if (this.driver) {
|
|
251
|
+
await this.driver.deleteSession();
|
|
252
|
+
this.driver = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async open(options) {
|
|
256
|
+
const driver = await this.session(options.bundleId);
|
|
257
|
+
try {
|
|
258
|
+
await driver.executeScript("mobile: terminateApp", [{ bundleId: options.bundleId }]);
|
|
259
|
+
} catch {}
|
|
260
|
+
await (options.deepLink === void 0 ? driver.executeScript("mobile: activateApp", [{ bundleId: options.bundleId }]) : driver.executeScript("mobile: deepLink", [{
|
|
261
|
+
bundleId: options.bundleId,
|
|
262
|
+
url: options.deepLink
|
|
263
|
+
}]));
|
|
264
|
+
if (options.scenario) try {
|
|
265
|
+
await options.scenario(this.createVisitor(driver));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const evidence = await this.captureEvidence(driver);
|
|
268
|
+
const suffix = evidence ? `\nEvidence: ${evidence}` : "";
|
|
269
|
+
if (error instanceof AmbiguousElementError) {
|
|
270
|
+
error.message += suffix;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`open scenario failed: ${error instanceof Error ? error.message : String(error)}${suffix}`, { cause: error });
|
|
274
|
+
}
|
|
275
|
+
return projectScreen(await driver.getPageSource());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The visitor implementation. Acting verbs (tap/fill) poll until exactly
|
|
279
|
+
* one visible match exists (CONVENTIONS W3); `see()` acts on nothing, so
|
|
280
|
+
* ANY visible match satisfies it — XCUITest trees legitimately expose the
|
|
281
|
+
* same text more than once (container/child label duplication), and a
|
|
282
|
+
* synchronization primitive refusing on that would punish honest screens.
|
|
283
|
+
*/
|
|
284
|
+
createVisitor(driver) {
|
|
285
|
+
return {
|
|
286
|
+
fill: async (element, value) => {
|
|
287
|
+
await (await this.resolveOne(driver, element, "fill", "one")).setValue(value);
|
|
288
|
+
},
|
|
289
|
+
see: async (element) => {
|
|
290
|
+
await this.resolveOne(driver, element, "see", "any");
|
|
291
|
+
},
|
|
292
|
+
tap: async (element) => {
|
|
293
|
+
await (await this.resolveOne(driver, element, "tap", "one")).click();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Resolve a descriptor: poll until at least one visible match exists,
|
|
299
|
+
* then enforce the verb's cardinality — `'one'` refuses ambiguity
|
|
300
|
+
* (CONVENTIONS W3), `'any'` settles for the first match. Scopes stay
|
|
301
|
+
* strict in BOTH modes: "within one of several containers" designates
|
|
302
|
+
* nothing, whatever the verb. The chain resolves outside-in, so an
|
|
303
|
+
* ambiguous scope names ITSELF as the fault rather than sending the
|
|
304
|
+
* author to fix the target.
|
|
305
|
+
*/
|
|
306
|
+
async resolveOne(driver, element, verb, cardinality) {
|
|
307
|
+
const chain = scopeChain(element);
|
|
308
|
+
const deadline = Date.now() + ACTION_TIMEOUT_MS;
|
|
309
|
+
let scrollAttempted = false;
|
|
310
|
+
for (;;) {
|
|
311
|
+
const resolved = await this.resolveChain(driver, chain, cardinality, { tryScroll: !scrollAttempted });
|
|
312
|
+
scrollAttempted = true;
|
|
313
|
+
if (resolved) return resolved;
|
|
314
|
+
if (Date.now() > deadline) throw await this.timeoutError(driver, element, verb);
|
|
315
|
+
await delay(POLL_INTERVAL_MS);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** One resolution pass over the chain — `null` means "nothing yet, keep polling". */
|
|
319
|
+
async resolveChain(driver, chain, cardinality, options) {
|
|
320
|
+
let scope = driver;
|
|
321
|
+
for (const [index, level] of chain.entries()) {
|
|
322
|
+
const matches = await scope.$$(`-ios predicate string:${compilePredicate(level)}`);
|
|
323
|
+
const count = await matches.length;
|
|
324
|
+
if (count === 0) {
|
|
325
|
+
if (options?.tryScroll) {
|
|
326
|
+
const offscreen = await scope.$$(`-ios predicate string:${compilePredicate(level, { anyVisibility: true })}`);
|
|
327
|
+
if (await offscreen.length > 0) try {
|
|
328
|
+
await driver.executeScript("mobile: scroll", [{
|
|
329
|
+
elementId: offscreen[0].elementId,
|
|
330
|
+
toVisible: true
|
|
331
|
+
}]);
|
|
332
|
+
} catch {}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const isTarget = index === chain.length - 1;
|
|
337
|
+
if (count > 1 && (cardinality === "one" || !isTarget)) throw new AmbiguousElementError(describeMobileAmbiguity({
|
|
338
|
+
element: level,
|
|
339
|
+
matches: await this.captureMatches(matches)
|
|
340
|
+
}));
|
|
341
|
+
scope = matches[0];
|
|
342
|
+
}
|
|
343
|
+
return scope;
|
|
344
|
+
}
|
|
345
|
+
/** Enumerate the candidates' evidence, truncated. */
|
|
346
|
+
async captureMatches(matches) {
|
|
347
|
+
const count = Math.min(await matches.length, MAX_REPORTED_MATCHES);
|
|
348
|
+
const evidence = [];
|
|
349
|
+
for (let index = 0; index < count; index++) evidence.push(await captureMatch(matches[index]));
|
|
350
|
+
return evidence;
|
|
351
|
+
}
|
|
352
|
+
/** Screenshot the failing state into a temp file; never masks the original error. */
|
|
353
|
+
async captureEvidence(driver) {
|
|
354
|
+
try {
|
|
355
|
+
const path = resolve(mkdtempSync(resolve(tmpdir(), "spec-mobile-")), "failure.png");
|
|
356
|
+
writeFileSync(path, Buffer.from(await driver.takeScreenshot(), "base64"));
|
|
357
|
+
return path;
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** The timeout refusal, with a compact excerpt of what IS on screen to aid diagnosis. */
|
|
363
|
+
async timeoutError(driver, element, verb) {
|
|
364
|
+
let excerpt = "";
|
|
365
|
+
try {
|
|
366
|
+
const source = await driver.getPageSource();
|
|
367
|
+
const labels = [...new Set([...source.matchAll(/label="(?<label>[^"]{2,60})"/g)].map((match) => match.groups?.["label"] ?? ""))].slice(0, 15);
|
|
368
|
+
if (labels.length > 0) excerpt = `\nCurrently on screen: ${labels.join(" | ")}`;
|
|
369
|
+
} catch {}
|
|
370
|
+
return /* @__PURE__ */ new Error(`${verb}(${formatElement(element)}) timed out after ${ACTION_TIMEOUT_MS}ms — no visible match.${excerpt}`);
|
|
371
|
+
}
|
|
372
|
+
/** Create the shared driver session (once), with an actionable error when webdriverio is absent. */
|
|
373
|
+
async session(bundleId) {
|
|
374
|
+
if (this.driver) return this.driver;
|
|
375
|
+
let remoteFn;
|
|
376
|
+
try {
|
|
377
|
+
({remote: remoteFn} = await import("webdriverio"));
|
|
378
|
+
} catch {
|
|
379
|
+
throw new Error(".open() requires webdriverio (optional peer dependency): npm install -D appium webdriverio && npx appium driver install xcuitest");
|
|
380
|
+
}
|
|
381
|
+
const url = new URL(this.options.serverUrl);
|
|
382
|
+
this.driver = await remoteFn({
|
|
383
|
+
capabilities: {
|
|
384
|
+
"appium:automationName": "XCUITest",
|
|
385
|
+
"appium:bundleId": bundleId,
|
|
386
|
+
"appium:noReset": true,
|
|
387
|
+
"appium:udid": this.options.udid,
|
|
388
|
+
"appium:wdaLaunchTimeout": WDA_LAUNCH_TIMEOUT_MS,
|
|
389
|
+
platformName: "iOS"
|
|
390
|
+
},
|
|
391
|
+
hostname: url.hostname,
|
|
392
|
+
logLevel: "error",
|
|
393
|
+
port: Number(url.port)
|
|
394
|
+
});
|
|
395
|
+
return this.driver;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
//#endregion
|
|
399
|
+
export { AppiumAdapter };
|