@delacour/warden-e2e 0.0.0-stage → 0.1.1
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 +21 -0
- package/chunk-qtkmfjbq.mjs +4585 -0
- package/index.d.ts +1 -0
- package/index.mjs +998 -0
- package/package.json +49 -4
- package/preload.d.ts +1 -0
- package/preload.mjs +5512 -0
- package/types/core/src/allocate.d.ts +23 -0
- package/types/core/src/builds/app-option.schema.d.ts +37 -0
- package/types/core/src/builds/install.d.ts +95 -0
- package/types/core/src/builds/resolve.d.ts +74 -0
- package/types/core/src/config.defaults.d.ts +25 -0
- package/types/core/src/exec.d.ts +18 -0
- package/types/core/src/liveness.d.ts +12 -0
- package/types/core/src/providers/android.d.ts +124 -0
- package/types/core/src/providers/health.d.ts +45 -0
- package/types/core/src/providers/provider.types.d.ts +56 -0
- package/types/core/src/store.d.ts +91 -0
- package/types/core/src/types.d.ts +106 -0
- package/types/e2e/src/alert-buttons.d.ts +41 -0
- package/types/e2e/src/alert-reader.d.ts +25 -0
- package/types/e2e/src/app.d.ts +62 -0
- package/types/e2e/src/device-devtools.d.ts +26 -0
- package/types/e2e/src/device-link.d.ts +43 -0
- package/types/e2e/src/device.d.ts +82 -0
- package/types/e2e/src/devtools/cdp.d.ts +65 -0
- package/types/e2e/src/devtools/console.d.ts +59 -0
- package/types/e2e/src/devtools/devtools.types.d.ts +169 -0
- package/types/e2e/src/devtools/facet.d.ts +28 -0
- package/types/e2e/src/devtools/inspector.d.ts +48 -0
- package/types/e2e/src/devtools/jsonl.d.ts +4 -0
- package/types/e2e/src/devtools/network.d.ts +33 -0
- package/types/e2e/src/devtools/network.script.d.ts +24 -0
- package/types/e2e/src/devtools/session.d.ts +82 -0
- package/types/e2e/src/devtools/tree.d.ts +23 -0
- package/types/e2e/src/devtools/tree.script.d.ts +33 -0
- package/types/e2e/src/driver/alerts.d.ts +29 -0
- package/types/e2e/src/driver/android/adb-session.d.ts +52 -0
- package/types/e2e/src/driver/android/android-alert.d.ts +4 -0
- package/types/e2e/src/driver/android/android-animations.d.ts +29 -0
- package/types/e2e/src/driver/android/android-app.d.ts +32 -0
- package/types/e2e/src/driver/android/android-argv.d.ts +122 -0
- package/types/e2e/src/driver/android/android-device.d.ts +44 -0
- package/types/e2e/src/driver/android/android-driver.d.ts +60 -0
- package/types/e2e/src/driver/android/android-dump.d.ts +46 -0
- package/types/e2e/src/driver/android/android-state.d.ts +69 -0
- package/types/e2e/src/driver/android/uiad-backend.d.ts +63 -0
- package/types/e2e/src/driver/android/uiad-client.d.ts +60 -0
- package/types/e2e/src/driver/android/uiad-device.d.ts +70 -0
- package/types/e2e/src/driver/android/uiad-dex.generated.d.ts +8 -0
- package/types/e2e/src/driver/android/uiad-ops.d.ts +62 -0
- package/types/e2e/src/driver/android/uiad-protocol.d.ts +209 -0
- package/types/e2e/src/driver/android/uiautomator.d.ts +33 -0
- package/types/e2e/src/driver/driver.types.d.ts +231 -0
- package/types/e2e/src/driver/errors.d.ts +13 -0
- package/types/e2e/src/driver/gesture-geometry.d.ts +48 -0
- package/types/e2e/src/driver/ios/ios-driver.d.ts +63 -0
- package/types/e2e/src/driver/ios/ios-session.d.ts +30 -0
- package/types/e2e/src/driver/ios/locate-helper.d.ts +38 -0
- package/types/e2e/src/driver/ios/normalize.d.ts +11 -0
- package/types/e2e/src/driver/ios/simctl.d.ts +87 -0
- package/types/e2e/src/driver/ios/simd-client.d.ts +68 -0
- package/types/e2e/src/driver/ios/simd-protocol.d.ts +433 -0
- package/types/e2e/src/driver/ios/simd-transport.d.ts +43 -0
- package/types/e2e/src/driver/ios/type-budget.d.ts +6 -0
- package/types/e2e/src/driver/observation-cache.d.ts +66 -0
- package/types/e2e/src/driver/open.d.ts +29 -0
- package/types/e2e/src/driver/settle.d.ts +43 -0
- package/types/e2e/src/driver/span-detail.d.ts +12 -0
- package/types/e2e/src/driver/traced-driver.d.ts +14 -0
- package/types/e2e/src/driver/typing.d.ts +48 -0
- package/types/e2e/src/driver/unwrap.d.ts +13 -0
- package/types/e2e/src/env.d.ts +61 -0
- package/types/e2e/src/errors.d.ts +26 -0
- package/types/e2e/src/expect/expect.d.ts +27 -0
- package/types/e2e/src/expect/matchers.d.ts +72 -0
- package/types/e2e/src/fixture-session.d.ts +34 -0
- package/types/e2e/src/fixtures.d.ts +14 -0
- package/types/e2e/src/index.d.ts +8 -0
- package/types/e2e/src/preload.d.ts +29 -0
- package/types/e2e/src/runner/artifacts.d.ts +13 -0
- package/types/e2e/src/runner/attempt.d.ts +63 -0
- package/types/e2e/src/runner/callsite.d.ts +2 -0
- package/types/e2e/src/runner/failure.d.ts +28 -0
- package/types/e2e/src/runner/fixtures.types.d.ts +60 -0
- package/types/e2e/src/runner/results.d.ts +48 -0
- package/types/e2e/src/runner/scope.d.ts +62 -0
- package/types/e2e/src/runner/span.d.ts +40 -0
- package/types/e2e/src/runner/step.d.ts +7 -0
- package/types/e2e/src/runner/test.d.ts +52 -0
- package/types/e2e/src/runner/trace.d.ts +87 -0
- package/types/e2e/src/screen/actions.d.ts +93 -0
- package/types/e2e/src/screen/context.d.ts +134 -0
- package/types/e2e/src/screen/field.d.ts +121 -0
- package/types/e2e/src/screen/locator.d.ts +233 -0
- package/types/e2e/src/screen/nodes.d.ts +53 -0
- package/types/e2e/src/screen/poll.d.ts +57 -0
- package/types/e2e/src/screen/queries.d.ts +34 -0
- package/types/e2e/src/screen/query.d.ts +60 -0
- package/types/e2e/src/screen/react-locator.d.ts +30 -0
- package/types/e2e/src/screen/render.d.ts +42 -0
- package/types/e2e/src/screen/roles.d.ts +24 -0
- package/types/e2e/src/screen/screen.d.ts +61 -0
- package/types/e2e/src/screen/selector.d.ts +38 -0
- package/types/e2e/src/screen/text.d.ts +13 -0
- package/types/e2e/src/session.d.ts +105 -0
- package/types/e2e/src/traced-device.d.ts +9 -0
- package/types/e2e/src/version.d.ts +7 -0
- package/types/types/src/result.d.ts +12 -0
- package/README.md +0 -3
package/index.mjs
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
BUN_TIMEOUT_MARGIN_MS,
|
|
4
|
+
E2eError,
|
|
5
|
+
WARDEN_E2E_VERSION,
|
|
6
|
+
attemptDir,
|
|
7
|
+
attemptSession,
|
|
8
|
+
callerFile,
|
|
9
|
+
canObserve,
|
|
10
|
+
createScope,
|
|
11
|
+
currentScope,
|
|
12
|
+
fieldValue,
|
|
13
|
+
flowIdOf,
|
|
14
|
+
getSession,
|
|
15
|
+
hash6,
|
|
16
|
+
hooksFor,
|
|
17
|
+
isLocator,
|
|
18
|
+
markOnly,
|
|
19
|
+
matchesText,
|
|
20
|
+
normalizeText,
|
|
21
|
+
pickOne,
|
|
22
|
+
registrationDefaults,
|
|
23
|
+
renderHeader,
|
|
24
|
+
renderSnapshot,
|
|
25
|
+
resolve,
|
|
26
|
+
resolveUse,
|
|
27
|
+
runInScope,
|
|
28
|
+
runStep,
|
|
29
|
+
textOf
|
|
30
|
+
} from "./chunk-qtkmfjbq.mjs";
|
|
31
|
+
// ../../packages/e2e/src/expect/expect.ts
|
|
32
|
+
import { expect as bunExpect } from "bun:test";
|
|
33
|
+
|
|
34
|
+
// ../../packages/e2e/src/expect/matchers.ts
|
|
35
|
+
var NEGATION_GRACE_MS = 1000;
|
|
36
|
+
var NEGATION_QUIET_MS = 250;
|
|
37
|
+
var NOT_FOUND = "element not found";
|
|
38
|
+
function missing() {
|
|
39
|
+
return { kind: "missing" };
|
|
40
|
+
}
|
|
41
|
+
function judged(matches, received) {
|
|
42
|
+
return { kind: "judged", matches, received };
|
|
43
|
+
}
|
|
44
|
+
function show(match) {
|
|
45
|
+
return typeof match === "string" ? JSON.stringify(match) : String(match);
|
|
46
|
+
}
|
|
47
|
+
function assertOptions(matcher, options, allowed) {
|
|
48
|
+
const unknown = Object.keys(options).filter((key) => !allowed.includes(key));
|
|
49
|
+
if (unknown.length > 0) {
|
|
50
|
+
throw new E2eError("INVALID_ARGUMENT", `${matcher} does not take ${unknown.map((key) => `"${key}"`).join(", ")}; it takes ${allowed.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
const bag = options;
|
|
53
|
+
for (const key of ["checked", "exact", "ignoreCase"]) {
|
|
54
|
+
if (bag[key] !== undefined && typeof bag[key] !== "boolean") {
|
|
55
|
+
throw new E2eError("INVALID_ARGUMENT", `${matcher}: "${key}" must be a boolean`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const timeout = bag.timeout;
|
|
59
|
+
if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0)) {
|
|
60
|
+
throw new E2eError("INVALID_ARGUMENT", `${matcher}: "timeout" must be a non-negative number of milliseconds`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function failUnlessRetryable(error) {
|
|
64
|
+
if (!error.retryable)
|
|
65
|
+
throw new E2eError("DRIVER", `snapshot failed: ${error.code}: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
function regexTest(pattern, value, ignoreCase = false) {
|
|
68
|
+
const flags = pattern.flags.replaceAll(/[gy]/g, "");
|
|
69
|
+
return new RegExp(pattern.source, ignoreCase && !flags.includes("i") ? `${flags}i` : flags).test(value);
|
|
70
|
+
}
|
|
71
|
+
function containsText(value, expected, ignoreCase) {
|
|
72
|
+
if (typeof expected !== "string")
|
|
73
|
+
return regexTest(expected, normalizeText(value), ignoreCase);
|
|
74
|
+
const haystack = normalizeText(value);
|
|
75
|
+
const needle = normalizeText(expected);
|
|
76
|
+
return ignoreCase ? haystack.toLowerCase().includes(needle.toLowerCase()) : haystack.includes(needle);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class LocatorAssertions {
|
|
80
|
+
locator;
|
|
81
|
+
context;
|
|
82
|
+
constructor(locator, context) {
|
|
83
|
+
this.locator = locator;
|
|
84
|
+
this.context = context;
|
|
85
|
+
}
|
|
86
|
+
get not() {
|
|
87
|
+
return new LocatorAssertions(this.locator, { ...this.context, negate: !this.context.negate });
|
|
88
|
+
}
|
|
89
|
+
async toBeVisible(options = {}) {
|
|
90
|
+
assertOptions("toBeVisible", options, ["timeout"]);
|
|
91
|
+
return this.assert({
|
|
92
|
+
call: "toBeVisible()",
|
|
93
|
+
expected: "visible",
|
|
94
|
+
strict: true,
|
|
95
|
+
judge: (entry) => entry === undefined ? judged(false, "not found") : judged(entry.visible, entry.visible ? "visible" : "hidden")
|
|
96
|
+
}, options);
|
|
97
|
+
}
|
|
98
|
+
async toBeHidden(options = {}) {
|
|
99
|
+
assertOptions("toBeHidden", options, ["timeout"]);
|
|
100
|
+
return this.assert({
|
|
101
|
+
call: "toBeHidden()",
|
|
102
|
+
expected: "hidden",
|
|
103
|
+
strict: true,
|
|
104
|
+
judge: (entry) => entry === undefined ? judged(true, "not found") : judged(!entry.visible, entry.visible ? "visible" : "hidden")
|
|
105
|
+
}, options);
|
|
106
|
+
}
|
|
107
|
+
async toHaveText(expected, options = {}) {
|
|
108
|
+
assertOptions("toHaveText", options, ["exact", "timeout"]);
|
|
109
|
+
const exact = options.exact ?? true;
|
|
110
|
+
return this.assert({
|
|
111
|
+
call: `toHaveText(${show(expected)})`,
|
|
112
|
+
expected: exact ? show(expected) : `${show(expected)} (substring, ignoring case)`,
|
|
113
|
+
strict: true,
|
|
114
|
+
judge: (entry) => {
|
|
115
|
+
if (entry === undefined)
|
|
116
|
+
return missing();
|
|
117
|
+
const value = textOf(entry);
|
|
118
|
+
return judged(matchesText(value, expected, { exact }), JSON.stringify(value));
|
|
119
|
+
}
|
|
120
|
+
}, options);
|
|
121
|
+
}
|
|
122
|
+
async toContainText(expected, options = {}) {
|
|
123
|
+
assertOptions("toContainText", options, ["ignoreCase", "timeout"]);
|
|
124
|
+
const ignoreCase = options.ignoreCase ?? false;
|
|
125
|
+
return this.assert({
|
|
126
|
+
call: `toContainText(${show(expected)})`,
|
|
127
|
+
expected: `text containing ${show(expected)}`,
|
|
128
|
+
strict: true,
|
|
129
|
+
judge: (entry) => {
|
|
130
|
+
if (entry === undefined)
|
|
131
|
+
return missing();
|
|
132
|
+
const value = textOf(entry);
|
|
133
|
+
return judged(containsText(value, expected, ignoreCase), JSON.stringify(value));
|
|
134
|
+
}
|
|
135
|
+
}, options);
|
|
136
|
+
}
|
|
137
|
+
async toHaveValue(expected, options = {}) {
|
|
138
|
+
assertOptions("toHaveValue", options, ["timeout"]);
|
|
139
|
+
return this.assert({
|
|
140
|
+
call: `toHaveValue(${show(expected)})`,
|
|
141
|
+
expected: show(expected),
|
|
142
|
+
strict: true,
|
|
143
|
+
judge: (entry) => {
|
|
144
|
+
if (entry === undefined)
|
|
145
|
+
return missing();
|
|
146
|
+
if (entry.secure) {
|
|
147
|
+
throw new E2eError("INVALID_ARGUMENT", "toHaveValue cannot judge a secure field: its value is withheld. Assert the outcome of the fill instead.");
|
|
148
|
+
}
|
|
149
|
+
const value = fieldValue(entry);
|
|
150
|
+
const matches = typeof expected === "string" ? value === expected : regexTest(expected, value);
|
|
151
|
+
return judged(matches, JSON.stringify(value));
|
|
152
|
+
}
|
|
153
|
+
}, options);
|
|
154
|
+
}
|
|
155
|
+
async toBeChecked(options = {}) {
|
|
156
|
+
assertOptions("toBeChecked", options, ["checked", "timeout"]);
|
|
157
|
+
const want = options.checked ?? true;
|
|
158
|
+
return this.assert({
|
|
159
|
+
call: options.checked === undefined ? "toBeChecked()" : `toBeChecked({ checked: ${want} })`,
|
|
160
|
+
expected: want ? "checked" : "unchecked",
|
|
161
|
+
strict: true,
|
|
162
|
+
judge: (entry) => {
|
|
163
|
+
if (entry === undefined)
|
|
164
|
+
return missing();
|
|
165
|
+
const checked = entry.states.checked ?? false;
|
|
166
|
+
return judged(checked === want, checked ? "checked" : "unchecked");
|
|
167
|
+
}
|
|
168
|
+
}, options);
|
|
169
|
+
}
|
|
170
|
+
async toBeEnabled(options = {}) {
|
|
171
|
+
assertOptions("toBeEnabled", options, ["timeout"]);
|
|
172
|
+
return this.assert({
|
|
173
|
+
call: "toBeEnabled()",
|
|
174
|
+
expected: "enabled",
|
|
175
|
+
strict: true,
|
|
176
|
+
judge: (entry) => entry === undefined ? missing() : judged(!entry.states.disabled, entry.states.disabled ? "disabled" : "enabled")
|
|
177
|
+
}, options);
|
|
178
|
+
}
|
|
179
|
+
async toBeDisabled(options = {}) {
|
|
180
|
+
assertOptions("toBeDisabled", options, ["timeout"]);
|
|
181
|
+
return this.assert({
|
|
182
|
+
call: "toBeDisabled()",
|
|
183
|
+
expected: "disabled",
|
|
184
|
+
strict: true,
|
|
185
|
+
judge: (entry) => entry === undefined ? missing() : judged(entry.states.disabled, entry.states.disabled ? "disabled" : "enabled")
|
|
186
|
+
}, options);
|
|
187
|
+
}
|
|
188
|
+
async toHaveCount(expected, options = {}) {
|
|
189
|
+
assertOptions("toHaveCount", options, ["timeout"]);
|
|
190
|
+
if (!Number.isInteger(expected) || expected < 0) {
|
|
191
|
+
throw new E2eError("INVALID_ARGUMENT", `toHaveCount takes a non-negative integer, got ${String(expected)}`);
|
|
192
|
+
}
|
|
193
|
+
return this.assert({
|
|
194
|
+
call: `toHaveCount(${expected})`,
|
|
195
|
+
expected: String(expected),
|
|
196
|
+
strict: false,
|
|
197
|
+
judge: (_entry, count) => judged(count === expected, String(count))
|
|
198
|
+
}, options);
|
|
199
|
+
}
|
|
200
|
+
async assert(spec, options) {
|
|
201
|
+
const rt = this.locator.runtime;
|
|
202
|
+
const timeoutMs = options.timeout ?? rt.assertionTimeout();
|
|
203
|
+
try {
|
|
204
|
+
await rt.step(`expect ${this.locator}.${spec.call}`, () => this.poll(spec, timeoutMs));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (this.context.soft !== undefined && error instanceof E2eError && error.code !== "TIMEOUT") {
|
|
207
|
+
this.context.soft(error);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async poll(spec, timeoutMs) {
|
|
214
|
+
const rt = this.locator.runtime;
|
|
215
|
+
const { negate } = this.context;
|
|
216
|
+
const hold = conditionHold(negate, Math.min(NEGATION_GRACE_MS, timeoutMs), rt.clock.now);
|
|
217
|
+
const result = await rt.poll(async (attempt) => {
|
|
218
|
+
const observation = await rt.tryObserve({ reuse: attempt === 1 });
|
|
219
|
+
if (!observation.ok) {
|
|
220
|
+
failUnlessRetryable(observation.error);
|
|
221
|
+
hold.broken();
|
|
222
|
+
return { done: false, state: { received: "(screen unreadable)" } };
|
|
223
|
+
}
|
|
224
|
+
const { observed } = observation;
|
|
225
|
+
const entries = resolve(this.locator.expr, observed.index);
|
|
226
|
+
if (spec.strict && entries.length > 1)
|
|
227
|
+
pickOne(rt, this.locator.expr, entries, observed);
|
|
228
|
+
const judgement = spec.judge(entries[0], entries.length);
|
|
229
|
+
const verdict = await hold.next(judgement.kind === "judged" && judgement.matches !== negate, observed.driver);
|
|
230
|
+
if (verdict === "pass")
|
|
231
|
+
return { done: true, value: true };
|
|
232
|
+
return {
|
|
233
|
+
done: false,
|
|
234
|
+
state: {
|
|
235
|
+
received: judgement.kind === "missing" ? NOT_FOUND : judgement.received,
|
|
236
|
+
snapshot: observed.snapshot
|
|
237
|
+
},
|
|
238
|
+
...verdict.wakeMs === undefined ? {} : { maxWaitMs: verdict.wakeMs }
|
|
239
|
+
};
|
|
240
|
+
}, timeoutMs);
|
|
241
|
+
if (result.status === "done")
|
|
242
|
+
return;
|
|
243
|
+
throw this.failure(spec, result.last);
|
|
244
|
+
}
|
|
245
|
+
failure(spec, last) {
|
|
246
|
+
const { negate } = this.context;
|
|
247
|
+
const lines = [
|
|
248
|
+
`expect(${this.locator})${negate ? ".not" : ""}.${spec.call}`,
|
|
249
|
+
`Expected: ${negate ? "not " : ""}${spec.expected}`,
|
|
250
|
+
`Received: ${last?.received ?? NOT_FOUND}`
|
|
251
|
+
];
|
|
252
|
+
const screen = last?.snapshot === undefined ? undefined : this.locator.runtime.excerpt(last.snapshot);
|
|
253
|
+
const message = screen === undefined ? lines.join(`
|
|
254
|
+
`) : `${lines.join(`
|
|
255
|
+
`)}
|
|
256
|
+
|
|
257
|
+
${screen}`;
|
|
258
|
+
return new E2eError("ASSERTION", message, screen);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function conditionHold(negate, holdMs, now) {
|
|
262
|
+
let since;
|
|
263
|
+
let asked = false;
|
|
264
|
+
let quiet = false;
|
|
265
|
+
const broken = () => {
|
|
266
|
+
since = undefined;
|
|
267
|
+
quiet = false;
|
|
268
|
+
};
|
|
269
|
+
return {
|
|
270
|
+
broken,
|
|
271
|
+
async next(holds, driver) {
|
|
272
|
+
if (!holds) {
|
|
273
|
+
broken();
|
|
274
|
+
return { wakeMs: undefined };
|
|
275
|
+
}
|
|
276
|
+
since ??= now();
|
|
277
|
+
if (!negate || quiet || now() - since >= holdMs)
|
|
278
|
+
return "pass";
|
|
279
|
+
if (!asked && driver.capabilities.nativeSettle && noRecentInput(driver)) {
|
|
280
|
+
asked = true;
|
|
281
|
+
quiet = await settledQuiet(driver, since + holdMs - now());
|
|
282
|
+
}
|
|
283
|
+
return { wakeMs: quiet ? 0 : since + holdMs - now() };
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function noRecentInput(driver) {
|
|
288
|
+
const previous = canObserve(driver) ? driver.previous?.() : undefined;
|
|
289
|
+
if (previous === undefined)
|
|
290
|
+
return false;
|
|
291
|
+
return previous.sinceInputMs === undefined || previous.sinceInputMs >= NEGATION_GRACE_MS;
|
|
292
|
+
}
|
|
293
|
+
async function settledQuiet(driver, timeoutMs) {
|
|
294
|
+
if (timeoutMs <= 0)
|
|
295
|
+
return false;
|
|
296
|
+
const settled = await driver.settle({ quietMs: NEGATION_QUIET_MS, timeoutMs });
|
|
297
|
+
return settled.success && settled.data.quiet;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ../../packages/e2e/src/expect/expect.ts
|
|
301
|
+
function softSinkFor(session) {
|
|
302
|
+
const failures = session.attempt?.softFailures;
|
|
303
|
+
return failures === undefined ? undefined : (error) => void failures.push(error);
|
|
304
|
+
}
|
|
305
|
+
function currentSoftSink() {
|
|
306
|
+
try {
|
|
307
|
+
return softSinkFor(getSession());
|
|
308
|
+
} catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
var valueExpect = bunExpect;
|
|
313
|
+
function asAssertion(error) {
|
|
314
|
+
if (error instanceof E2eError)
|
|
315
|
+
return error;
|
|
316
|
+
return new E2eError("ASSERTION", error instanceof Error ? error.message : String(error));
|
|
317
|
+
}
|
|
318
|
+
function isThenable(value) {
|
|
319
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
320
|
+
}
|
|
321
|
+
function softMatchers(target, sink) {
|
|
322
|
+
return new Proxy(target, {
|
|
323
|
+
get(object, property) {
|
|
324
|
+
const value = Reflect.get(object, property, object);
|
|
325
|
+
if (typeof value === "function") {
|
|
326
|
+
return (...args) => {
|
|
327
|
+
try {
|
|
328
|
+
const result = Reflect.apply(value, object, args);
|
|
329
|
+
return isThenable(result) ? Promise.resolve(result).catch((e) => sink(asAssertion(e))) : result;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
sink(asAssertion(error));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (typeof value === "object" && value !== null)
|
|
337
|
+
return softMatchers(value, sink);
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
var NOT_STATIC = new Set(["length", "name", "prototype"]);
|
|
343
|
+
function createExpect(deps) {
|
|
344
|
+
const call = (actual, message) => isLocator(actual) ? new LocatorAssertions(actual, { negate: false }) : valueExpect(actual, message);
|
|
345
|
+
const soft = (actual, message) => {
|
|
346
|
+
const sink = deps.softSink();
|
|
347
|
+
if (isLocator(actual))
|
|
348
|
+
return new LocatorAssertions(actual, { negate: false, ...sink === undefined ? {} : { soft: sink } });
|
|
349
|
+
const matchers = valueExpect(actual, message);
|
|
350
|
+
return sink === undefined ? matchers : softMatchers(matchers, sink);
|
|
351
|
+
};
|
|
352
|
+
for (const key of Reflect.ownKeys(bunExpect)) {
|
|
353
|
+
if (NOT_STATIC.has(key))
|
|
354
|
+
continue;
|
|
355
|
+
Object.defineProperty(call, key, {
|
|
356
|
+
enumerable: true,
|
|
357
|
+
get() {
|
|
358
|
+
const value = Reflect.get(bunExpect, key);
|
|
359
|
+
return typeof value === "function" ? value.bind(bunExpect) : value;
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
Object.defineProperty(call, "soft", { enumerable: true, value: soft });
|
|
364
|
+
return call;
|
|
365
|
+
}
|
|
366
|
+
var expect = createExpect({ softSink: currentSoftSink });
|
|
367
|
+
// ../../packages/e2e/src/runner/test.ts
|
|
368
|
+
import { afterAll as bunAfterAll, beforeAll as bunBeforeAll, describe as bunDescribe, test as bunTest } from "bun:test";
|
|
369
|
+
|
|
370
|
+
// ../../packages/e2e/src/runner/attempt.ts
|
|
371
|
+
import { readdirSync } from "fs";
|
|
372
|
+
import { join as join2 } from "path";
|
|
373
|
+
|
|
374
|
+
// ../../packages/e2e/src/runner/failure.ts
|
|
375
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
376
|
+
import { join } from "path";
|
|
377
|
+
var FAILURE_PNG = "failure.png";
|
|
378
|
+
var SCREEN_AT_FAILURE = "screen-at-failure.txt";
|
|
379
|
+
var CONSOLE_JSONL = "console.jsonl";
|
|
380
|
+
var NETWORK_JSONL = "network.jsonl";
|
|
381
|
+
var CAPTURE_TIMEOUT_MS = 1e4;
|
|
382
|
+
|
|
383
|
+
class CaptureTimeout extends Error {
|
|
384
|
+
}
|
|
385
|
+
function limited(promise, ms) {
|
|
386
|
+
let timer;
|
|
387
|
+
const timeout = new Promise((_, reject) => {
|
|
388
|
+
timer = setTimeout(() => reject(new CaptureTimeout(`timed out after ${ms}ms`)), ms);
|
|
389
|
+
});
|
|
390
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
391
|
+
}
|
|
392
|
+
var unlimited = () => Number.POSITIVE_INFINITY;
|
|
393
|
+
var SPENT = "skipped, the post-deadline budget is used up";
|
|
394
|
+
async function captureFailure(driver, dir, timeoutMs = CAPTURE_TIMEOUT_MS, budget = unlimited) {
|
|
395
|
+
const capture = { files: [], problems: [] };
|
|
396
|
+
try {
|
|
397
|
+
mkdirSync(dir, { recursive: true });
|
|
398
|
+
} catch (cause) {
|
|
399
|
+
capture.problems.push(`mkdir ${dir}: ${String(cause)}`);
|
|
400
|
+
return capture;
|
|
401
|
+
}
|
|
402
|
+
const shot = await captureScreenshot(driver, dir, Math.min(timeoutMs, budget()), capture);
|
|
403
|
+
if (shot === "unanswered") {
|
|
404
|
+
capture.problems.push("snapshot: skipped, the screenshot timed out (the device is not answering)");
|
|
405
|
+
return capture;
|
|
406
|
+
}
|
|
407
|
+
await captureSnapshot(driver, dir, Math.min(timeoutMs, budget()), capture);
|
|
408
|
+
return capture;
|
|
409
|
+
}
|
|
410
|
+
var message = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
411
|
+
async function captureScreenshot(driver, dir, ms, capture) {
|
|
412
|
+
if (ms <= 0) {
|
|
413
|
+
capture.problems.push(`screenshot: ${SPENT}`);
|
|
414
|
+
return "answered";
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const shot = await limited(driver.screenshot({ path: join(dir, FAILURE_PNG) }), ms);
|
|
418
|
+
if (!shot.success) {
|
|
419
|
+
capture.problems.push(`screenshot: ${shot.error.code}: ${shot.error.message}`);
|
|
420
|
+
return "answered";
|
|
421
|
+
}
|
|
422
|
+
if (shot.data.path === undefined && shot.data.png !== undefined) {
|
|
423
|
+
writeFileSync(join(dir, FAILURE_PNG), shot.data.png);
|
|
424
|
+
}
|
|
425
|
+
capture.files.push(FAILURE_PNG);
|
|
426
|
+
} catch (cause) {
|
|
427
|
+
capture.problems.push(`screenshot: ${message(cause)}`);
|
|
428
|
+
if (cause instanceof CaptureTimeout)
|
|
429
|
+
return "unanswered";
|
|
430
|
+
}
|
|
431
|
+
return "answered";
|
|
432
|
+
}
|
|
433
|
+
async function captureSnapshot(driver, dir, ms, capture) {
|
|
434
|
+
if (ms <= 0) {
|
|
435
|
+
capture.problems.push(`snapshot: ${SPENT}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
const result = await limited(driver.snapshot(), ms);
|
|
440
|
+
if (!result.success) {
|
|
441
|
+
capture.problems.push(`snapshot: ${result.error.code}: ${result.error.message}`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const rendered = renderSnapshot(result.data, { interactiveOnly: false, refs: new Map });
|
|
445
|
+
const header = renderHeader({ snapshot: result.data, rendered, interactiveOnly: false });
|
|
446
|
+
writeFileSync(join(dir, SCREEN_AT_FAILURE), `${[header, ...rendered.lines].join(`
|
|
447
|
+
`)}
|
|
448
|
+
`);
|
|
449
|
+
capture.files.push(SCREEN_AT_FAILURE);
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
capture.problems.push(`snapshot: ${message(cause)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function captureDevtoolsArtifacts(facet, dir, timeoutMs = CAPTURE_TIMEOUT_MS, budget = unlimited) {
|
|
455
|
+
const capture = { files: [], problems: [] };
|
|
456
|
+
const ms = Math.min(timeoutMs, budget());
|
|
457
|
+
if (ms <= 0) {
|
|
458
|
+
capture.problems.push(`devtools artifacts: ${SPENT}`);
|
|
459
|
+
return capture;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const written = await limited(facet.attachArtifacts(dir), ms);
|
|
463
|
+
if (written.success)
|
|
464
|
+
capture.files.push(CONSOLE_JSONL, NETWORK_JSONL);
|
|
465
|
+
else
|
|
466
|
+
capture.problems.push(`devtools artifacts: ${written.error.code}: ${written.error.message}`);
|
|
467
|
+
} catch (cause) {
|
|
468
|
+
capture.problems.push(`devtools artifacts: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
469
|
+
}
|
|
470
|
+
return capture;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ../../packages/e2e/src/runner/trace.ts
|
|
474
|
+
import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
475
|
+
import { dirname } from "path";
|
|
476
|
+
import { z } from "zod";
|
|
477
|
+
var base = { ts: z.number(), kind: z.string(), label: z.string() };
|
|
478
|
+
var traceEventSchema = z.discriminatedUnion("type", [
|
|
479
|
+
z.object({ type: z.literal("step-start"), ...base }),
|
|
480
|
+
z.object({
|
|
481
|
+
type: z.literal("step-end"),
|
|
482
|
+
...base,
|
|
483
|
+
status: z.enum(["passed", "failed"]),
|
|
484
|
+
ms: z.number(),
|
|
485
|
+
error: z.string().optional()
|
|
486
|
+
}),
|
|
487
|
+
z.object({ type: z.literal("screenshot"), ...base, path: z.string() }),
|
|
488
|
+
z.object({ type: z.literal("note"), ...base }),
|
|
489
|
+
z.object({
|
|
490
|
+
type: z.literal("span"),
|
|
491
|
+
ts: z.number(),
|
|
492
|
+
op: z.string(),
|
|
493
|
+
ms: z.number(),
|
|
494
|
+
ok: z.boolean(),
|
|
495
|
+
code: z.string().optional(),
|
|
496
|
+
detail: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
497
|
+
})
|
|
498
|
+
]);
|
|
499
|
+
var SPAN_BUFFER_MAX = 256;
|
|
500
|
+
function openTrace(path, mirror) {
|
|
501
|
+
let ready = false;
|
|
502
|
+
let pending = [];
|
|
503
|
+
const append = (lines) => {
|
|
504
|
+
if (!ready) {
|
|
505
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
506
|
+
ready = true;
|
|
507
|
+
}
|
|
508
|
+
appendFileSync(path, lines.join(""));
|
|
509
|
+
};
|
|
510
|
+
const flush = () => {
|
|
511
|
+
if (pending.length === 0)
|
|
512
|
+
return;
|
|
513
|
+
const lines = pending;
|
|
514
|
+
pending = [];
|
|
515
|
+
append(lines);
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
path,
|
|
519
|
+
write(event) {
|
|
520
|
+
const line = `${JSON.stringify(event)}
|
|
521
|
+
`;
|
|
522
|
+
if (event.type === "span") {
|
|
523
|
+
pending.push(line);
|
|
524
|
+
if (pending.length >= SPAN_BUFFER_MAX)
|
|
525
|
+
flush();
|
|
526
|
+
} else {
|
|
527
|
+
const lines = pending;
|
|
528
|
+
pending = [];
|
|
529
|
+
append([...lines, line]);
|
|
530
|
+
}
|
|
531
|
+
mirror?.(event);
|
|
532
|
+
},
|
|
533
|
+
flush
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
537
|
+
function clockOf(ts) {
|
|
538
|
+
const d = new Date(ts);
|
|
539
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
540
|
+
}
|
|
541
|
+
function shortName(file) {
|
|
542
|
+
return (file.split("/").pop() ?? file).replace(/\.e2e\.[cm]?[jt]sx?$/, "").replace(/\.[cm]?[jt]sx?$/, "");
|
|
543
|
+
}
|
|
544
|
+
var deviceLabel = (device) => device.name ?? (device.udid.length > 9 ? `${device.udid.slice(0, 9)}\u2026` : device.udid);
|
|
545
|
+
var testName = (test) => `${test.file} \u203A ${test.title.join(" \u203A ")}`;
|
|
546
|
+
function lineOf(event, opts) {
|
|
547
|
+
const short = opts.short ?? "test";
|
|
548
|
+
const indent = " ".repeat((opts.depth ?? 0) + 1);
|
|
549
|
+
switch (event.type) {
|
|
550
|
+
case "test-start":
|
|
551
|
+
return { ts: event.test.startedAt, text: `\u25B6 test |${event.test.device.platform}| ${testName(event.test)}` };
|
|
552
|
+
case "test-end": {
|
|
553
|
+
const r = event.result;
|
|
554
|
+
return {
|
|
555
|
+
ts: r.startedAt + r.durationMs,
|
|
556
|
+
text: `\u25A0 test |${r.device.platform}| ${testName(r)} ${r.status} ${(r.durationMs / 1000).toFixed(2)}s on ${deviceLabel(r.device)}`
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
case "step-start":
|
|
560
|
+
return { ts: event.ts, text: `${indent}\u25B8 ${short} \u203A ${event.kind} ${event.label}` };
|
|
561
|
+
case "step-end": {
|
|
562
|
+
const error = event.error ? ` \u2014 ${event.error}` : "";
|
|
563
|
+
return {
|
|
564
|
+
ts: event.ts,
|
|
565
|
+
text: `${indent}\u25AA ${short} \u203A ${event.kind} ${event.label} ${event.status} ${event.ms}ms${error}`
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
default:
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function formatConsoleLine(event, opts) {
|
|
573
|
+
const line = lineOf(event, opts);
|
|
574
|
+
if (line === undefined)
|
|
575
|
+
return;
|
|
576
|
+
return opts.clock ? `[${clockOf(line.ts)}] ${line.text}` : line.text;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ../../packages/e2e/src/runner/attempt.ts
|
|
580
|
+
var CLEANUP_TIMEOUT_MS = 1e4;
|
|
581
|
+
var POST_DEADLINE_RESERVE_MS = 5000;
|
|
582
|
+
var DEFAULT_TIMING = {
|
|
583
|
+
cleanupTimeoutMs: CLEANUP_TIMEOUT_MS,
|
|
584
|
+
captureTimeoutMs: CAPTURE_TIMEOUT_MS,
|
|
585
|
+
bunMarginMs: BUN_TIMEOUT_MARGIN_MS,
|
|
586
|
+
reserveMs: POST_DEADLINE_RESERVE_MS
|
|
587
|
+
};
|
|
588
|
+
var timing = { ...DEFAULT_TIMING };
|
|
589
|
+
function runnerTiming() {
|
|
590
|
+
return timing;
|
|
591
|
+
}
|
|
592
|
+
function unavailable(name) {
|
|
593
|
+
return new Proxy({}, {
|
|
594
|
+
get(_target, prop) {
|
|
595
|
+
if (typeof prop === "symbol" || prop === "then")
|
|
596
|
+
return;
|
|
597
|
+
throw new E2eError("SESSION", `the \`${name}\` fixture is not available: no fixture factory is registered on the session`);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
async function buildBaseFixtures(session, signal, use) {
|
|
602
|
+
if (session.fixtures === undefined) {
|
|
603
|
+
return {
|
|
604
|
+
app: unavailable("app"),
|
|
605
|
+
device: unavailable("device"),
|
|
606
|
+
screen: unavailable("screen"),
|
|
607
|
+
platform: session.env.platform
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return session.fixtures({
|
|
611
|
+
session,
|
|
612
|
+
signal,
|
|
613
|
+
launch: use.launch,
|
|
614
|
+
actionTimeoutMs: use.actionTimeoutMs,
|
|
615
|
+
assertionTimeoutMs: use.assertionTimeoutMs,
|
|
616
|
+
systemAlerts: use.systemAlerts
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
async function startFixture(entry, bag) {
|
|
620
|
+
let provide = () => {
|
|
621
|
+
return;
|
|
622
|
+
};
|
|
623
|
+
let fail = () => {
|
|
624
|
+
return;
|
|
625
|
+
};
|
|
626
|
+
let release = () => {
|
|
627
|
+
return;
|
|
628
|
+
};
|
|
629
|
+
let used = false;
|
|
630
|
+
const value = new Promise((resolve2, reject) => {
|
|
631
|
+
provide = resolve2;
|
|
632
|
+
fail = reject;
|
|
633
|
+
});
|
|
634
|
+
const released = new Promise((resolve2) => {
|
|
635
|
+
release = resolve2;
|
|
636
|
+
});
|
|
637
|
+
const finished = entry.setup(bag, async (v) => {
|
|
638
|
+
used = true;
|
|
639
|
+
provide(v);
|
|
640
|
+
await released;
|
|
641
|
+
}).then(() => {
|
|
642
|
+
if (!used)
|
|
643
|
+
fail(new E2eError("INVALID_ARGUMENT", `fixture \`${entry.name}\` finished without calling use()`));
|
|
644
|
+
}, (error) => {
|
|
645
|
+
if (!used)
|
|
646
|
+
fail(error);
|
|
647
|
+
else
|
|
648
|
+
throw error;
|
|
649
|
+
});
|
|
650
|
+
finished.catch(() => {
|
|
651
|
+
return;
|
|
652
|
+
});
|
|
653
|
+
const resolved = await value;
|
|
654
|
+
return {
|
|
655
|
+
value: resolved,
|
|
656
|
+
teardown: async () => {
|
|
657
|
+
release();
|
|
658
|
+
await finished;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function cleanupDeadline(startedAt, timeoutMs, bunTimeoutMs) {
|
|
663
|
+
const t = runnerTiming();
|
|
664
|
+
return startedAt + Math.min(timeoutMs + t.bunMarginMs, bunTimeoutMs ?? Number.POSITIVE_INFINITY) - t.reserveMs;
|
|
665
|
+
}
|
|
666
|
+
function limited2(label, ms, fn) {
|
|
667
|
+
let timer;
|
|
668
|
+
const timeout = new Promise((_, reject) => {
|
|
669
|
+
timer = setTimeout(() => reject(new E2eError("TIMEOUT", `${label} timed out after ${ms}ms`)), ms);
|
|
670
|
+
});
|
|
671
|
+
return Promise.race([fn(), timeout]).finally(() => clearTimeout(timer));
|
|
672
|
+
}
|
|
673
|
+
function listScreenshots(dir) {
|
|
674
|
+
try {
|
|
675
|
+
return readdirSync(join2(dir, "screenshots")).map((name) => `screenshots/${name}`);
|
|
676
|
+
} catch {
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function errorInfo(error) {
|
|
681
|
+
if (error instanceof Error) {
|
|
682
|
+
return {
|
|
683
|
+
message: error.message,
|
|
684
|
+
...error instanceof E2eError ? { code: error.code } : {},
|
|
685
|
+
...error.stack !== undefined ? { stack: error.stack } : {}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return { message: String(error) };
|
|
689
|
+
}
|
|
690
|
+
async function runAttempt(session, spec) {
|
|
691
|
+
const { env } = session;
|
|
692
|
+
const use = resolveUse(spec.scope, { timeoutMs: env.timeoutMs, actionTimeoutMs: env.actionTimeoutMs, assertionTimeoutMs: env.assertionTimeoutMs }, spec.timeout);
|
|
693
|
+
const startedAt = Date.now();
|
|
694
|
+
const cleanupBy = cleanupDeadline(startedAt, use.timeoutMs, spec.bunTimeoutMs);
|
|
695
|
+
const budget = () => cleanupBy - Date.now();
|
|
696
|
+
const device = { udid: env.udid, platform: env.platform };
|
|
697
|
+
const clock = !env.logTimestamps;
|
|
698
|
+
const short = shortName(spec.file);
|
|
699
|
+
const dir = attemptDir(session.out, flowIdOf(spec.file, env.flowsDir), spec.title, hash6(spec.file, ...spec.title));
|
|
700
|
+
const print = (line) => {
|
|
701
|
+
if (line !== undefined)
|
|
702
|
+
session.log(line);
|
|
703
|
+
};
|
|
704
|
+
const files = new Set;
|
|
705
|
+
let binding;
|
|
706
|
+
const trace = openTrace(join2(dir, "trace.jsonl"), (event) => {
|
|
707
|
+
files.add("trace.jsonl");
|
|
708
|
+
print(formatConsoleLine(event, { clock, short, depth: binding?.context.depth ?? 0 }));
|
|
709
|
+
});
|
|
710
|
+
const controller = new AbortController;
|
|
711
|
+
const ctx = {
|
|
712
|
+
signal: controller.signal,
|
|
713
|
+
dir,
|
|
714
|
+
short,
|
|
715
|
+
trace,
|
|
716
|
+
depth: 0,
|
|
717
|
+
files,
|
|
718
|
+
softFailures: []
|
|
719
|
+
};
|
|
720
|
+
binding = { context: ctx, finished: false };
|
|
721
|
+
const bound = attemptSession(session, () => binding ?? { context: ctx, finished: false });
|
|
722
|
+
print(formatConsoleLine({ type: "test-start", test: { file: spec.file, title: [...spec.title], startedAt, device } }, { clock }));
|
|
723
|
+
session.enterAttempt(ctx);
|
|
724
|
+
let deadlineHit = false;
|
|
725
|
+
const timer = setTimeout(() => {
|
|
726
|
+
deadlineHit = true;
|
|
727
|
+
controller.abort(new E2eError("TIMEOUT", `test timed out after ${use.timeoutMs}ms`));
|
|
728
|
+
}, use.timeoutMs);
|
|
729
|
+
const deadline = new Promise((_, reject) => {
|
|
730
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
|
|
731
|
+
});
|
|
732
|
+
deadline.catch(() => {
|
|
733
|
+
return;
|
|
734
|
+
});
|
|
735
|
+
const teardowns = [];
|
|
736
|
+
const state = {};
|
|
737
|
+
let failed = false;
|
|
738
|
+
let failure;
|
|
739
|
+
try {
|
|
740
|
+
await Promise.race([setUpAndRun(bound, spec, use, controller.signal, state, teardowns), deadline]);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
failed = true;
|
|
743
|
+
failure = error;
|
|
744
|
+
} finally {
|
|
745
|
+
clearTimeout(timer);
|
|
746
|
+
}
|
|
747
|
+
if (!failed && ctx.softFailures.length > 0) {
|
|
748
|
+
failed = true;
|
|
749
|
+
failure = softFailureError(ctx.softFailures);
|
|
750
|
+
}
|
|
751
|
+
if (failed)
|
|
752
|
+
await captureFailureArtifacts(session, ctx, budget);
|
|
753
|
+
const cleanupErrors = await runCleanup(session, spec, ctx, state.bag, teardowns, budget, (context) => {
|
|
754
|
+
binding = { context, finished: false };
|
|
755
|
+
});
|
|
756
|
+
const finished = new E2eError("SESSION", "attempt finished");
|
|
757
|
+
if (!controller.signal.aborted)
|
|
758
|
+
controller.abort(finished);
|
|
759
|
+
binding = { context: { ...ctx, signal: AbortSignal.abort(finished), depth: 0 }, finished: true };
|
|
760
|
+
session.leaveAttempt(ctx);
|
|
761
|
+
trace.flush();
|
|
762
|
+
if (!failed && cleanupErrors.length > 0) {
|
|
763
|
+
failed = true;
|
|
764
|
+
failure = cleanupErrors[0];
|
|
765
|
+
}
|
|
766
|
+
for (const shot of listScreenshots(dir))
|
|
767
|
+
files.add(shot);
|
|
768
|
+
const outcome = failed ? { failed: true, error: failure, timedOut: deadlineHit } : { failed: false };
|
|
769
|
+
const result = buildResult(spec, ctx, { startedAt, device }, outcome);
|
|
770
|
+
print(formatConsoleLine({ type: "test-end", result }, { clock }));
|
|
771
|
+
if (!failed || spec.final)
|
|
772
|
+
session.results.append(result);
|
|
773
|
+
if (failed)
|
|
774
|
+
throw failure instanceof Error ? failure : new Error(String(failure));
|
|
775
|
+
}
|
|
776
|
+
function softFailureError(failures) {
|
|
777
|
+
if (failures.length === 1 && failures[0] !== undefined)
|
|
778
|
+
return failures[0];
|
|
779
|
+
const body = failures.map((failure, index) => `${index + 1}) ${failure.message}`).join(`
|
|
780
|
+
|
|
781
|
+
`);
|
|
782
|
+
return new E2eError("ASSERTION", `${failures.length} soft assertions failed:
|
|
783
|
+
|
|
784
|
+
${body}`, failures[0]?.screen);
|
|
785
|
+
}
|
|
786
|
+
function buildResult(spec, ctx, run, outcome) {
|
|
787
|
+
const status = !outcome.failed ? "passed" : outcome.timedOut ? "timed-out" : "failed";
|
|
788
|
+
return {
|
|
789
|
+
file: spec.file,
|
|
790
|
+
title: [...spec.title],
|
|
791
|
+
status,
|
|
792
|
+
durationMs: Date.now() - run.startedAt,
|
|
793
|
+
startedAt: run.startedAt,
|
|
794
|
+
...spec.only ? { only: true } : {},
|
|
795
|
+
...outcome.failed ? { error: errorInfo(outcome.error) } : {},
|
|
796
|
+
...ctx.files.size > 0 ? { artifacts: { dir: ctx.dir, files: [...ctx.files].sort() } } : {},
|
|
797
|
+
device: run.device
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
async function setUpAndRun(session, spec, use, signal, state, teardowns) {
|
|
801
|
+
const base2 = await buildBaseFixtures(session, signal, use);
|
|
802
|
+
const built = { ...base2 };
|
|
803
|
+
state.bag = built;
|
|
804
|
+
if (use.launch === "fresh")
|
|
805
|
+
await session.step("app", "app.open", () => base2.app.open());
|
|
806
|
+
for (const entry of spec.fixtures) {
|
|
807
|
+
const started = await session.step("fixture", entry.name, () => startFixture(entry, built));
|
|
808
|
+
built[entry.name] = started.value;
|
|
809
|
+
teardowns.push({ name: entry.name, run: started.teardown });
|
|
810
|
+
}
|
|
811
|
+
for (const hook of hooksFor(spec.scope, "beforeEach")) {
|
|
812
|
+
await session.step("hook", "beforeEach", async () => hook(built));
|
|
813
|
+
}
|
|
814
|
+
await spec.body(built);
|
|
815
|
+
}
|
|
816
|
+
async function captureFailureArtifacts(session, ctx, budget) {
|
|
817
|
+
const driver = session.peekDriver();
|
|
818
|
+
const facet = session.peekDevtools();
|
|
819
|
+
const { captureTimeoutMs } = runnerTiming();
|
|
820
|
+
const captures = [
|
|
821
|
+
...driver === undefined ? [] : [await captureFailure(driver, ctx.dir, captureTimeoutMs, budget)],
|
|
822
|
+
...facet === undefined ? [] : [await captureDevtoolsArtifacts(facet, ctx.dir, captureTimeoutMs, budget)]
|
|
823
|
+
];
|
|
824
|
+
for (const capture of captures) {
|
|
825
|
+
for (const file of capture.files)
|
|
826
|
+
ctx.files.add(file);
|
|
827
|
+
for (const problem of capture.problems) {
|
|
828
|
+
ctx.trace.write({ type: "note", ts: Date.now(), kind: "note", label: `failure capture: ${problem}` });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function runCleanup(session, spec, ctx, built, teardowns, budget, bind) {
|
|
833
|
+
const cleanup = new AbortController;
|
|
834
|
+
const cleanupCtx = { ...ctx, signal: cleanup.signal, depth: 0 };
|
|
835
|
+
session.enterAttempt(cleanupCtx, ctx);
|
|
836
|
+
bind(cleanupCtx);
|
|
837
|
+
const step = (kind, label, fn) => runStep(cleanupCtx, kind, label, fn);
|
|
838
|
+
const errors = [];
|
|
839
|
+
const attempt = async (label, fn) => {
|
|
840
|
+
try {
|
|
841
|
+
const left = budget();
|
|
842
|
+
if (left <= 0)
|
|
843
|
+
throw new E2eError("TIMEOUT", `${label} skipped: the cleanup budget after the test is used up`);
|
|
844
|
+
await limited2(label, Math.min(timing.cleanupTimeoutMs, left), fn);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
errors.push(error);
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
if (built !== undefined) {
|
|
850
|
+
for (const hook of hooksFor(spec.scope, "afterEach")) {
|
|
851
|
+
await attempt("afterEach", () => step("hook", "afterEach", async () => hook(built)));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
for (const fixture of [...teardowns].reverse()) {
|
|
855
|
+
await attempt(`fixture ${fixture.name} teardown`, () => step("fixture", `${fixture.name} teardown`, fixture.run));
|
|
856
|
+
}
|
|
857
|
+
cleanup.abort(new E2eError("SESSION", "attempt finished"));
|
|
858
|
+
session.leaveAttempt(cleanupCtx);
|
|
859
|
+
return errors;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ../../packages/e2e/src/runner/fixtures.types.ts
|
|
863
|
+
var SYSTEM_ALERTS = ["accept", "dismiss", "off"];
|
|
864
|
+
|
|
865
|
+
// ../../packages/e2e/src/runner/test.ts
|
|
866
|
+
var registered = new WeakSet;
|
|
867
|
+
function markRegistered(scope) {
|
|
868
|
+
for (let s = scope;s !== undefined; s = s.parent)
|
|
869
|
+
registered.add(s);
|
|
870
|
+
}
|
|
871
|
+
function registerTest(fixtures, mode, title, second, third) {
|
|
872
|
+
const options = typeof second === "function" ? {} : second;
|
|
873
|
+
const body = typeof second === "function" ? second : third;
|
|
874
|
+
if (body === undefined) {
|
|
875
|
+
throw new E2eError("INVALID_ARGUMENT", `test("${title}"): the test body is missing`);
|
|
876
|
+
}
|
|
877
|
+
const scope = currentScope();
|
|
878
|
+
const file = callerFile();
|
|
879
|
+
const path = [...scope.title, title];
|
|
880
|
+
const defaults = registrationDefaults();
|
|
881
|
+
const use = resolveUse(scope, defaults, options.timeout);
|
|
882
|
+
const retry = options.retry ?? 0;
|
|
883
|
+
const wrongPlatform = options.platforms !== undefined && defaults.platform !== undefined && !options.platforms.includes(defaults.platform);
|
|
884
|
+
const only = mode === "only" || options.only === true;
|
|
885
|
+
if (only)
|
|
886
|
+
markOnly();
|
|
887
|
+
markRegistered(scope);
|
|
888
|
+
const bunTimeoutMs = use.timeoutMs + runnerTiming().bunMarginMs;
|
|
889
|
+
let attempts = 0;
|
|
890
|
+
const run = async () => {
|
|
891
|
+
attempts += 1;
|
|
892
|
+
await runAttempt(getSession(), {
|
|
893
|
+
file,
|
|
894
|
+
title: path,
|
|
895
|
+
scope,
|
|
896
|
+
fixtures,
|
|
897
|
+
body,
|
|
898
|
+
...options.timeout !== undefined ? { timeout: options.timeout } : {},
|
|
899
|
+
only,
|
|
900
|
+
final: attempts > retry,
|
|
901
|
+
bunTimeoutMs
|
|
902
|
+
});
|
|
903
|
+
};
|
|
904
|
+
const bunOptions = { timeout: bunTimeoutMs, retry };
|
|
905
|
+
if (mode === "skip" || options.skip === true || wrongPlatform)
|
|
906
|
+
bunTest.skip(title, run, bunOptions);
|
|
907
|
+
else if (only)
|
|
908
|
+
bunTest.only(title, run, bunOptions);
|
|
909
|
+
else
|
|
910
|
+
bunTest(title, run, bunOptions);
|
|
911
|
+
}
|
|
912
|
+
function eraseSetup(setup) {
|
|
913
|
+
return (base2, use) => setup(base2, use);
|
|
914
|
+
}
|
|
915
|
+
function isKeyOf(object, key) {
|
|
916
|
+
return key in object;
|
|
917
|
+
}
|
|
918
|
+
function makeCallable(fixtures, mode) {
|
|
919
|
+
return (title, second, third) => registerTest(fixtures, mode, title, second, third);
|
|
920
|
+
}
|
|
921
|
+
function makeTest(fixtures) {
|
|
922
|
+
const base2 = makeCallable(fixtures, "run");
|
|
923
|
+
return Object.assign(base2, {
|
|
924
|
+
skip: makeCallable(fixtures, "skip"),
|
|
925
|
+
only: makeCallable(fixtures, "only"),
|
|
926
|
+
todo(title) {
|
|
927
|
+
bunTest.todo(title, () => {
|
|
928
|
+
throw new E2eError("INVALID_ARGUMENT", `test.todo("${title}") is not implemented`);
|
|
929
|
+
});
|
|
930
|
+
},
|
|
931
|
+
skipIf(condition) {
|
|
932
|
+
return makeCallable(fixtures, condition ? "skip" : "run");
|
|
933
|
+
},
|
|
934
|
+
use(options) {
|
|
935
|
+
if (options.systemAlerts !== undefined && !SYSTEM_ALERTS.includes(options.systemAlerts)) {
|
|
936
|
+
throw new E2eError("INVALID_ARGUMENT", `test.use: systemAlerts must be one of ${SYSTEM_ALERTS.map((mode) => `"${mode}"`).join(", ")}, got ${JSON.stringify(options.systemAlerts)}`);
|
|
937
|
+
}
|
|
938
|
+
const scope = currentScope();
|
|
939
|
+
if (options.timeout !== undefined && registered.has(scope)) {
|
|
940
|
+
process.stderr.write(`warden: test.use({ timeout: ${options.timeout} }) comes after a test in the same scope; bun fixed that ` + `test's own timeout when it was registered, so put test.use above the tests it is for
|
|
941
|
+
`);
|
|
942
|
+
}
|
|
943
|
+
scope.use = { ...scope.use, ...options };
|
|
944
|
+
},
|
|
945
|
+
extend(defs) {
|
|
946
|
+
const added = [];
|
|
947
|
+
for (const name of Object.keys(defs)) {
|
|
948
|
+
if (isKeyOf(defs, name))
|
|
949
|
+
added.push({ name, setup: eraseSetup(defs[name]) });
|
|
950
|
+
}
|
|
951
|
+
return makeTest([...fixtures, ...added]);
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
var test = makeTest([]);
|
|
956
|
+
function describe(title, second, third) {
|
|
957
|
+
const body = typeof second === "function" ? second : third;
|
|
958
|
+
const options = typeof second === "function" ? {} : second;
|
|
959
|
+
if (body === undefined)
|
|
960
|
+
throw new E2eError("INVALID_ARGUMENT", `describe("${title}"): the body is missing`);
|
|
961
|
+
const scope = createScope(title, options.serial === true);
|
|
962
|
+
bunDescribe(title, () => runInScope(scope, body));
|
|
963
|
+
}
|
|
964
|
+
function beforeEach(hook) {
|
|
965
|
+
currentScope().beforeEach.push(hook);
|
|
966
|
+
}
|
|
967
|
+
function afterEach(hook) {
|
|
968
|
+
currentScope().afterEach.push(hook);
|
|
969
|
+
}
|
|
970
|
+
async function sharedFixtures(scope) {
|
|
971
|
+
const session = getSession();
|
|
972
|
+
const defaults = session.env;
|
|
973
|
+
const use = resolveUse(scope, {
|
|
974
|
+
timeoutMs: defaults.timeoutMs,
|
|
975
|
+
actionTimeoutMs: defaults.actionTimeoutMs,
|
|
976
|
+
assertionTimeoutMs: defaults.assertionTimeoutMs
|
|
977
|
+
});
|
|
978
|
+
return buildBaseFixtures(session, session.signal, use);
|
|
979
|
+
}
|
|
980
|
+
function beforeAll(hook) {
|
|
981
|
+
const scope = currentScope();
|
|
982
|
+
bunBeforeAll(async () => hook(await sharedFixtures(scope)));
|
|
983
|
+
}
|
|
984
|
+
function afterAll(hook) {
|
|
985
|
+
const scope = currentScope();
|
|
986
|
+
bunAfterAll(async () => hook(await sharedFixtures(scope)));
|
|
987
|
+
}
|
|
988
|
+
export {
|
|
989
|
+
test,
|
|
990
|
+
expect,
|
|
991
|
+
describe,
|
|
992
|
+
beforeEach,
|
|
993
|
+
beforeAll,
|
|
994
|
+
afterEach,
|
|
995
|
+
afterAll,
|
|
996
|
+
WARDEN_E2E_VERSION,
|
|
997
|
+
E2eError
|
|
998
|
+
};
|