@delacour/warden-e2e 0.13.0 → 0.15.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/{chunk-tnr47n9c.mjs → chunk-e3cry5br.mjs} +828 -255
- package/index.mjs +144 -14
- package/package.json +2 -2
- package/preload.mjs +375 -63
- package/types/core/src/affected/affected.schema.d.ts +5 -0
- package/types/core/src/builds/builds.defaults.d.ts +8 -0
- package/types/core/src/builds/config.schema.d.ts +9 -0
- package/types/core/src/builds/define-config.d.ts +9 -1
- package/types/core/src/conditions/conditions.config.d.ts +6 -1
- package/types/core/src/conditions/conditions.types.d.ts +21 -0
- package/types/core/src/conditions/inject-state.d.ts +3 -2
- package/types/e2e/src/a11y/a11y-target.d.ts +2 -0
- package/types/e2e/src/conditions/condition-events.d.ts +6 -1
- package/types/e2e/src/conditions/device-conditions.d.ts +8 -2
- package/types/e2e/src/device.d.ts +10 -2
- package/types/e2e/src/driver/android/android-settings.d.ts +9 -2
- package/types/e2e/src/driver/ax-wedge.d.ts +38 -0
- package/types/e2e/src/driver/bridge-stall.d.ts +41 -0
- package/types/e2e/src/driver/driver.types.d.ts +35 -2
- package/types/e2e/src/driver/ios/inject.d.ts +17 -2
- package/types/e2e/src/driver/ios/ios-driver.d.ts +17 -2
- package/types/e2e/src/driver/ios/ios-keyboard.d.ts +39 -0
- package/types/e2e/src/driver/ios/simd-client.d.ts +2 -0
- package/types/e2e/src/driver/ios/simd-protocol.d.ts +129 -0
- package/types/e2e/src/driver/keyboard-state.d.ts +9 -0
- package/types/e2e/src/driver/physical-conditions.d.ts +1 -1
- package/types/e2e/src/driver/read-deadline.d.ts +4 -0
- package/types/e2e/src/driver/settle.d.ts +11 -1
- package/types/e2e/src/errors.d.ts +3 -1
- package/types/e2e/src/expect/a11y-matchers.d.ts +7 -0
- package/types/e2e/src/expect/matchers.d.ts +5 -0
- package/types/e2e/src/index.d.ts +2 -1
- package/types/e2e/src/runner/bridge-evidence.d.ts +12 -0
- package/types/e2e/src/runner/results.d.ts +29 -0
- package/types/e2e/src/screen/actions.d.ts +7 -0
- package/types/e2e/src/screen/context.d.ts +11 -12
- package/types/e2e/src/screen/degraded-read.d.ts +23 -0
- package/types/e2e/src/screen/keyboard-cover.d.ts +26 -0
- package/types/e2e/src/screen/locator.d.ts +61 -7
- package/types/e2e/src/screen/react-locator.d.ts +17 -2
- package/types/e2e/src/screen/react-merge.d.ts +10 -0
- package/types/e2e/src/session.d.ts +6 -0
package/index.mjs
CHANGED
|
@@ -3,17 +3,19 @@ import {
|
|
|
3
3
|
E2eError2,
|
|
4
4
|
a11yTargetOf,
|
|
5
5
|
canObserve,
|
|
6
|
+
isOnScreen,
|
|
6
7
|
fieldValue,
|
|
7
8
|
normalizeText,
|
|
8
9
|
matchesText,
|
|
9
10
|
renderSnapshot,
|
|
10
11
|
renderHeader,
|
|
11
|
-
resolve,
|
|
12
12
|
isLocator,
|
|
13
13
|
pickOne,
|
|
14
14
|
textOf,
|
|
15
15
|
formatNativeTail,
|
|
16
16
|
captureNativeLog,
|
|
17
|
+
bridgeStallFromSpan,
|
|
18
|
+
formatBridgeStall,
|
|
17
19
|
isRecoverableLoss,
|
|
18
20
|
parseDuration,
|
|
19
21
|
runStep,
|
|
@@ -44,11 +46,12 @@ import {
|
|
|
44
46
|
registrationDefaults,
|
|
45
47
|
markOnly,
|
|
46
48
|
WARDEN_E2E_VERSION2
|
|
47
|
-
} from "./chunk-
|
|
49
|
+
} from "./chunk-e3cry5br.mjs";
|
|
48
50
|
// ../../packages/e2e/src/expect/expect.ts
|
|
49
51
|
import { expect as bunExpect } from "bun:test";
|
|
50
52
|
|
|
51
53
|
// ../../packages/e2e/src/expect/a11y-matchers.ts
|
|
54
|
+
var KEYBOARD_POLL_MS = 100;
|
|
52
55
|
function show(match) {
|
|
53
56
|
return typeof match === "string" ? JSON.stringify(match) : String(match);
|
|
54
57
|
}
|
|
@@ -123,6 +126,30 @@ class DeviceAssertions {
|
|
|
123
126
|
]);
|
|
124
127
|
});
|
|
125
128
|
}
|
|
129
|
+
async toHaveKeyboard(options = {}) {
|
|
130
|
+
for (const key of Object.keys(options)) {
|
|
131
|
+
if (key !== "timeout")
|
|
132
|
+
throw new E2eError2("INVALID_ARGUMENT", `toHaveKeyboard does not take "${key}"; it takes timeout`);
|
|
133
|
+
}
|
|
134
|
+
checkMillis("toHaveKeyboard", "timeout", options.timeout);
|
|
135
|
+
const timeoutMs = options.timeout ?? this.target.assertionTimeout();
|
|
136
|
+
const want = !this.context.negate;
|
|
137
|
+
return this.run("toHaveKeyboard()", async () => {
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
let visible = await this.target.keyboardVisible();
|
|
140
|
+
while (visible !== want && Date.now() < deadline) {
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, KEYBOARD_POLL_MS));
|
|
142
|
+
visible = await this.target.keyboardVisible();
|
|
143
|
+
}
|
|
144
|
+
if (visible === want)
|
|
145
|
+
return;
|
|
146
|
+
throw await this.failure([
|
|
147
|
+
`expect(device)${this.context.negate ? ".not" : ""}.toHaveKeyboard()`,
|
|
148
|
+
`Expected: ${want ? "a keyboard on screen" : "no keyboard on screen"} within ${timeoutMs} ms`,
|
|
149
|
+
`Received: ${visible ? "a keyboard on screen" : "no keyboard on screen"}`
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
126
153
|
async failure(lines) {
|
|
127
154
|
const screen = await this.target.a11yScreen();
|
|
128
155
|
const head = lines.join(`
|
|
@@ -148,6 +175,15 @@ ${screen}`, screen);
|
|
|
148
175
|
var NEGATION_GRACE_MS = 1000;
|
|
149
176
|
var NEGATION_QUIET_MS = 250;
|
|
150
177
|
var NOT_FOUND = "element not found";
|
|
178
|
+
var UNKNOWN = "unknown (degraded read)";
|
|
179
|
+
function reactShown(matches, screen) {
|
|
180
|
+
const [only] = matches;
|
|
181
|
+
return only !== undefined && only.frame.width > 0 && only.frame.height > 0 && isOnScreen(only.tapPoint, screen);
|
|
182
|
+
}
|
|
183
|
+
function reactText(matches) {
|
|
184
|
+
const [only] = matches;
|
|
185
|
+
return only?.text === undefined ? undefined : normalizeText(only.text);
|
|
186
|
+
}
|
|
151
187
|
function missing() {
|
|
152
188
|
return { kind: "missing" };
|
|
153
189
|
}
|
|
@@ -205,7 +241,11 @@ class LocatorAssertions {
|
|
|
205
241
|
call: "toBeVisible()",
|
|
206
242
|
expected: "visible",
|
|
207
243
|
strict: true,
|
|
208
|
-
judge: (entry) => entry === undefined ? judged(false, "not found") : judged(entry.visible, entry.visible ? "visible" : "hidden")
|
|
244
|
+
judge: (entry) => entry === undefined ? judged(false, "not found") : judged(entry.visible, entry.visible ? "visible" : "hidden"),
|
|
245
|
+
judgeReact: (matches, screen) => {
|
|
246
|
+
const shown = reactShown(matches, screen);
|
|
247
|
+
return judged(shown, shown ? "visible (React tree)" : "hidden (React tree)");
|
|
248
|
+
}
|
|
209
249
|
}, options);
|
|
210
250
|
}
|
|
211
251
|
async toBeHidden(options = {}) {
|
|
@@ -214,7 +254,11 @@ class LocatorAssertions {
|
|
|
214
254
|
call: "toBeHidden()",
|
|
215
255
|
expected: "hidden",
|
|
216
256
|
strict: true,
|
|
217
|
-
judge: (entry) => entry === undefined ? judged(true, "not found") : judged(!entry.visible, entry.visible ? "visible" : "hidden")
|
|
257
|
+
judge: (entry) => entry === undefined ? judged(true, "not found") : judged(!entry.visible, entry.visible ? "visible" : "hidden"),
|
|
258
|
+
judgeReact: (matches, screen) => {
|
|
259
|
+
const shown = reactShown(matches, screen);
|
|
260
|
+
return judged(!shown, shown ? "visible (React tree)" : "hidden (React tree)");
|
|
261
|
+
}
|
|
218
262
|
}, options);
|
|
219
263
|
}
|
|
220
264
|
async toHaveText(expected, options = {}) {
|
|
@@ -229,6 +273,12 @@ class LocatorAssertions {
|
|
|
229
273
|
return missing();
|
|
230
274
|
const value = textOf(entry);
|
|
231
275
|
return judged(matchesText(value, expected, { exact }), JSON.stringify(value));
|
|
276
|
+
},
|
|
277
|
+
judgeReact: (matches) => {
|
|
278
|
+
const value = reactText(matches);
|
|
279
|
+
if (value === undefined)
|
|
280
|
+
return;
|
|
281
|
+
return judged(matchesText(value, expected, { exact }), `${JSON.stringify(value)} (React tree)`);
|
|
232
282
|
}
|
|
233
283
|
}, options);
|
|
234
284
|
}
|
|
@@ -244,6 +294,12 @@ class LocatorAssertions {
|
|
|
244
294
|
return missing();
|
|
245
295
|
const value = textOf(entry);
|
|
246
296
|
return judged(containsText(value, expected, ignoreCase), JSON.stringify(value));
|
|
297
|
+
},
|
|
298
|
+
judgeReact: (matches) => {
|
|
299
|
+
const value = reactText(matches);
|
|
300
|
+
if (value === undefined)
|
|
301
|
+
return;
|
|
302
|
+
return judged(containsText(value, expected, ignoreCase), `${JSON.stringify(value)} (React tree)`);
|
|
247
303
|
}
|
|
248
304
|
}, options);
|
|
249
305
|
}
|
|
@@ -379,7 +435,8 @@ class LocatorAssertions {
|
|
|
379
435
|
call: `toHaveCount(${expected})`,
|
|
380
436
|
expected: String(expected),
|
|
381
437
|
strict: false,
|
|
382
|
-
judge: (_entry, count) => judged(count === expected, String(count))
|
|
438
|
+
judge: (_entry, count) => judged(count === expected, String(count)),
|
|
439
|
+
judgeReact: (matches) => judged(matches.length === expected, `${matches.length} (React tree)`)
|
|
383
440
|
}, options);
|
|
384
441
|
}
|
|
385
442
|
async assert(spec, options) {
|
|
@@ -407,11 +464,12 @@ class LocatorAssertions {
|
|
|
407
464
|
return { done: false, state: { received: "(screen unreadable)" } };
|
|
408
465
|
}
|
|
409
466
|
const { observed } = observation;
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
467
|
+
const look = await this.judgeLook(spec, observed);
|
|
468
|
+
if (look.kind === "unsettled") {
|
|
469
|
+
hold.broken();
|
|
470
|
+
return { done: false, state: look.last };
|
|
471
|
+
}
|
|
472
|
+
const { judgement, seen } = look;
|
|
415
473
|
const verdict = await hold.next(judgement.kind === "judged" && judgement.matches !== negate, observed.driver);
|
|
416
474
|
if (verdict === "pass")
|
|
417
475
|
return { done: true, value: true };
|
|
@@ -426,8 +484,35 @@ class LocatorAssertions {
|
|
|
426
484
|
}, timeoutMs);
|
|
427
485
|
if (result.status === "done")
|
|
428
486
|
return;
|
|
487
|
+
const degraded = result.last?.degraded;
|
|
488
|
+
if (degraded !== undefined)
|
|
489
|
+
throw this.locator.degradedFailure(degraded, timeoutMs, result.last?.snapshot);
|
|
429
490
|
throw this.failure(spec, result.last);
|
|
430
491
|
}
|
|
492
|
+
async judgeLook(spec, observed) {
|
|
493
|
+
const rt = this.locator.runtime;
|
|
494
|
+
const resolved = await this.locator.resolveIn(observed);
|
|
495
|
+
if (resolved.kind === "unknown") {
|
|
496
|
+
return { kind: "unsettled", last: { received: UNKNOWN, snapshot: observed.snapshot, degraded: resolved.read } };
|
|
497
|
+
}
|
|
498
|
+
if (resolved.kind === "react") {
|
|
499
|
+
const { matches } = resolved;
|
|
500
|
+
if (spec.strict && matches.length > 1 && spec.judgeReact !== undefined) {
|
|
501
|
+
const message = `${this.locator} matched ${matches.length} components in the React tree`;
|
|
502
|
+
throw new E2eError2("LOCATOR_AMBIGUOUS", message, rt.excerpt(observed.snapshot), matches.length);
|
|
503
|
+
}
|
|
504
|
+
const judgement = spec.judgeReact?.(matches, observed.snapshot.screen);
|
|
505
|
+
if (judgement === undefined) {
|
|
506
|
+
return { kind: "unsettled", last: { received: "only in the React tree", snapshot: observed.snapshot } };
|
|
507
|
+
}
|
|
508
|
+
return { kind: "judged", judgement, seen: observed.snapshot };
|
|
509
|
+
}
|
|
510
|
+
const { entries } = resolved;
|
|
511
|
+
if (spec.strict && entries.length > 1)
|
|
512
|
+
pickOne(rt, this.locator.expr, entries, observed);
|
|
513
|
+
const { seen, context } = await judgementContext(spec, observed);
|
|
514
|
+
return { kind: "judged", judgement: spec.judge(entries[0], entries.length, context), seen };
|
|
515
|
+
}
|
|
431
516
|
failure(spec, last) {
|
|
432
517
|
const { negate } = this.context;
|
|
433
518
|
const lines = [
|
|
@@ -710,6 +795,36 @@ function missingRequirements(target, requires) {
|
|
|
710
795
|
import { readdirSync } from "fs";
|
|
711
796
|
import { join as join2 } from "path";
|
|
712
797
|
|
|
798
|
+
// ../../packages/e2e/src/runner/bridge-evidence.ts
|
|
799
|
+
function bridgeEvidence() {
|
|
800
|
+
let fallbacks = 0;
|
|
801
|
+
let first;
|
|
802
|
+
let last;
|
|
803
|
+
return {
|
|
804
|
+
observe(event) {
|
|
805
|
+
if (event.type !== "span" || event.detail === undefined)
|
|
806
|
+
return;
|
|
807
|
+
const detail = event.detail;
|
|
808
|
+
const stall = bridgeStallFromSpan(detail);
|
|
809
|
+
const reason = typeof detail.fallback === "string" ? detail.fallback : undefined;
|
|
810
|
+
const fellBack = reason !== undefined && detail.backend !== "axbridge";
|
|
811
|
+
if (stall === undefined && !fellBack)
|
|
812
|
+
return;
|
|
813
|
+
const read = {
|
|
814
|
+
...fellBack ? { reason } : {},
|
|
815
|
+
...event.ok || event.code === undefined ? {} : { code: event.code },
|
|
816
|
+
...stall
|
|
817
|
+
};
|
|
818
|
+
fallbacks += 1;
|
|
819
|
+
first ??= read;
|
|
820
|
+
last = read;
|
|
821
|
+
},
|
|
822
|
+
summary() {
|
|
823
|
+
return first === undefined || last === undefined ? undefined : { fallbacks, first, last };
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
713
828
|
// ../../packages/e2e/src/runner/failure.ts
|
|
714
829
|
import { mkdirSync, writeFileSync } from "fs";
|
|
715
830
|
import { join } from "path";
|
|
@@ -782,7 +897,9 @@ async function captureSnapshot(driver, dir, ms, capture) {
|
|
|
782
897
|
}
|
|
783
898
|
const rendered = renderSnapshot(result.data, { interactiveOnly: false, refs: new Map });
|
|
784
899
|
const header = renderHeader({ snapshot: result.data, rendered, interactiveOnly: false });
|
|
785
|
-
|
|
900
|
+
const fellBack = fallbackLine(result.data);
|
|
901
|
+
const lines = fellBack === undefined ? [header, ...rendered.lines] : [header, fellBack, ...rendered.lines];
|
|
902
|
+
writeFileSync(join(dir, SCREEN_AT_FAILURE), `${lines.join(`
|
|
786
903
|
`)}
|
|
787
904
|
`);
|
|
788
905
|
capture.files.push(SCREEN_AT_FAILURE);
|
|
@@ -790,6 +907,12 @@ async function captureSnapshot(driver, dir, ms, capture) {
|
|
|
790
907
|
capture.problems.push(`snapshot: ${message(cause)}`);
|
|
791
908
|
}
|
|
792
909
|
}
|
|
910
|
+
function fallbackLine(snapshot) {
|
|
911
|
+
if (snapshot.fellBackFrom === undefined)
|
|
912
|
+
return;
|
|
913
|
+
const head = `# read on ${snapshot.backend}: ${snapshot.fellBackFrom}`;
|
|
914
|
+
return snapshot.bridgeStall === undefined ? `${head} did not answer` : `${head} stalled (${formatBridgeStall(snapshot.bridgeStall)})`;
|
|
915
|
+
}
|
|
793
916
|
async function captureDevtoolsArtifacts(facet, dir, timeoutMs = CAPTURE_TIMEOUT_MS, budget = unlimited) {
|
|
794
917
|
const capture = { files: [], problems: [] };
|
|
795
918
|
const ms = Math.min(timeoutMs, budget());
|
|
@@ -1059,8 +1182,10 @@ async function runAttemptOnce(session, spec) {
|
|
|
1059
1182
|
};
|
|
1060
1183
|
const files = new Set;
|
|
1061
1184
|
let binding;
|
|
1185
|
+
const bridge = bridgeEvidence();
|
|
1062
1186
|
const trace = openTrace(join2(dir, "trace.jsonl"), (event) => {
|
|
1063
1187
|
files.add("trace.jsonl");
|
|
1188
|
+
bridge.observe(event);
|
|
1064
1189
|
print(formatConsoleLine(event, { clock, short, depth: binding?.context.depth ?? 0 }));
|
|
1065
1190
|
});
|
|
1066
1191
|
const controller = new AbortController;
|
|
@@ -1093,7 +1218,7 @@ async function runAttemptOnce(session, spec) {
|
|
|
1093
1218
|
session.leaveAttempt(ctx);
|
|
1094
1219
|
trace.flush();
|
|
1095
1220
|
outcome = failIf(outcome, cleanupErrors.length > 0, () => cleanupErrors[0]);
|
|
1096
|
-
return concludeAttempt(session, spec, ctx, { startedAt, device }, outcome, print, clock);
|
|
1221
|
+
return concludeAttempt(session, spec, ctx, { startedAt, device, bridge: bridge.summary() }, outcome, print, clock);
|
|
1097
1222
|
}
|
|
1098
1223
|
async function raceDeadline(controller, timeoutMs, run) {
|
|
1099
1224
|
let deadlineHit = false;
|
|
@@ -1124,15 +1249,19 @@ async function concludeAttempt(session, spec, ctx, run, outcome, print, clock) {
|
|
|
1124
1249
|
ctx.files.add(shot);
|
|
1125
1250
|
const result = buildResult(spec, ctx, run, outcome.failed ? { failed: true, error: outcome.failure, timedOut: outcome.deadlineHit } : { failed: false });
|
|
1126
1251
|
print(formatConsoleLine({ type: "test-end", result }, { clock }));
|
|
1127
|
-
if (outcome.failed && session
|
|
1252
|
+
if (outcome.failed && await recovered(session, outcome.failure, run.startedAt))
|
|
1128
1253
|
return "again";
|
|
1129
|
-
}
|
|
1130
1254
|
if (!outcome.failed || spec.final)
|
|
1131
1255
|
session.results.append(result);
|
|
1132
1256
|
if (outcome.failed)
|
|
1133
1257
|
throw outcome.failure instanceof Error ? outcome.failure : new Error(String(outcome.failure));
|
|
1134
1258
|
return;
|
|
1135
1259
|
}
|
|
1260
|
+
async function recovered(session, failure, startedAt) {
|
|
1261
|
+
if (session.recoverHid !== undefined && isRecoverableLoss(failure, session.env.deviceKind) && await session.recoverHid())
|
|
1262
|
+
return true;
|
|
1263
|
+
return session.recoverAxWedge !== undefined && await session.recoverAxWedge(Date.now() - startedAt);
|
|
1264
|
+
}
|
|
1136
1265
|
function softFailureError(failures) {
|
|
1137
1266
|
if (failures.length === 1 && failures[0] !== undefined)
|
|
1138
1267
|
return failures[0];
|
|
@@ -1154,6 +1283,7 @@ function buildResult(spec, ctx, run, outcome) {
|
|
|
1154
1283
|
...spec.only ? { only: true } : {},
|
|
1155
1284
|
...outcome.failed ? { error: errorInfo(outcome.error) } : {},
|
|
1156
1285
|
...ctx.files.size > 0 ? { artifacts: { dir: ctx.dir, files: [...ctx.files].sort() } } : {},
|
|
1286
|
+
...run.bridge === undefined ? {} : { bridge: run.bridge },
|
|
1157
1287
|
device: run.device
|
|
1158
1288
|
};
|
|
1159
1289
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/warden-e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "TypeScript e2e testing for React Native and Expo on iOS simulators and Android emulators, run by `warden e2e`: test, expect, screen, device, app",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@delacour/warden": "0.
|
|
44
|
+
"@delacour/warden": "0.15.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"zod": "^4.4.3"
|