@lingxia/test 0.17.0 → 0.19.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/dist/clock.d.ts +30 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +99 -0
- package/dist/clock.js.map +1 -0
- package/dist/coverage.d.ts +25 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +92 -0
- package/dist/coverage.js.map +1 -0
- package/dist/deadline.d.ts +82 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +184 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dialogs.d.ts +28 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.js +74 -0
- package/dist/dialogs.js.map +1 -0
- package/dist/equal.d.ts +28 -0
- package/dist/equal.d.ts.map +1 -0
- package/dist/equal.js +149 -0
- package/dist/equal.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/expect.d.ts +35 -7
- package/dist/expect.d.ts.map +1 -1
- package/dist/expect.js +216 -68
- package/dist/expect.js.map +1 -1
- package/dist/fixture.d.ts +205 -40
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +1178 -156
- package/dist/fixture.js.map +1 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +78 -0
- package/dist/format.js.map +1 -1
- package/dist/host-types.d.ts +65 -0
- package/dist/host-types.d.ts.map +1 -0
- package/dist/host-types.js +2 -0
- package/dist/host-types.js.map +1 -0
- package/dist/host.d.ts +20 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +34 -2
- package/dist/host.js.map +1 -1
- package/dist/ids.d.ts +16 -0
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +38 -3
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/json-result.d.ts +3 -0
- package/dist/json-result.d.ts.map +1 -0
- package/dist/json-result.js +62 -0
- package/dist/json-result.js.map +1 -0
- package/dist/junit.d.ts +1 -1
- package/dist/junit.d.ts.map +1 -1
- package/dist/junit.js +40 -5
- package/dist/junit.js.map +1 -1
- package/dist/locator.d.ts +116 -7
- package/dist/locator.d.ts.map +1 -1
- package/dist/locator.js +444 -58
- package/dist/locator.js.map +1 -1
- package/dist/mock.d.ts +43 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +162 -0
- package/dist/mock.js.map +1 -0
- package/dist/network.d.ts +68 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +287 -0
- package/dist/network.js.map +1 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +414 -0
- package/dist/openapi.js.map +1 -0
- package/dist/pending.d.ts +84 -0
- package/dist/pending.d.ts.map +1 -0
- package/dist/pending.js +311 -0
- package/dist/pending.js.map +1 -0
- package/dist/redact.d.ts +41 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +168 -0
- package/dist/redact.js.map +1 -0
- package/dist/remote.d.ts +32 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +117 -0
- package/dist/remote.js.map +1 -0
- package/dist/report-extras.d.ts +16 -0
- package/dist/report-extras.d.ts.map +1 -0
- package/dist/report-extras.js +125 -0
- package/dist/report-extras.js.map +1 -0
- package/dist/report-types.d.ts +428 -0
- package/dist/report-types.d.ts.map +1 -0
- package/dist/report-types.js +6 -0
- package/dist/report-types.js.map +1 -0
- package/dist/report.d.ts +15 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +196 -19
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts +13 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +11 -0
- package/dist/runner.js.map +1 -0
- package/dist/runtime.d.ts +55 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1500 -139
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +29 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +463 -0
- package/dist/schema.js.map +1 -0
- package/dist/spec-api.d.ts +10 -2
- package/dist/spec-api.d.ts.map +1 -1
- package/dist/tags.d.ts +26 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +78 -0
- package/dist/tags.js.map +1 -0
- package/dist/types.d.ts +863 -168
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -5
- package/schemas/lxdev.schema.json +50 -0
- package/schemas/mock-config.schema.json +21 -0
- package/schemas/scenario.schema.json +180 -0
package/dist/fixture.js
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
|
-
import { AssertionError, applyMatcher,
|
|
2
|
-
import { formatValue, truncate } from "./format.js";
|
|
1
|
+
import { AssertionError, applyMatcher, check, popAssertionSilence, pushAssertionSilence, setExpectScope, } from "./expect.js";
|
|
2
|
+
import { firstDifference, formatValue, truncate } from "./format.js";
|
|
3
3
|
import { encodeAttachPayload, remapStack } from "./host.js";
|
|
4
4
|
import { rememberInline } from "./report.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { NetworkScope, wrapNetwork } from "./network.js";
|
|
6
|
+
import { ScenarioScope, installScenario } from "./mock.js";
|
|
7
|
+
import { ClockScope, wrapClock } from "./clock.js";
|
|
8
|
+
import { watchDialogs, wrapDialogs } from "./dialogs.js";
|
|
9
|
+
import { activeOpenApi } from "./openapi.js";
|
|
10
|
+
import { ActionDeadline, TimeoutError, asFixtureTimeout } from "./deadline.js";
|
|
11
|
+
import { isTransientTransportError, matchesErrorCode } from "./deadline.js";
|
|
12
|
+
import { checkJsonArgs, explainRemoteError, isRetryableRemoteError, functionDetail, logicScript, pageScript } from "./remote.js";
|
|
13
|
+
import { callerLocation, displayLocation, isFrameworkFrame, parseFrames, resolveOrigin } from "./ids.js";
|
|
14
|
+
import { PageLocator, normalizeText, sleep, testIdSelector, } from "./locator.js";
|
|
7
15
|
import { DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_SPEC_TIMEOUT_MS, MAX_ACTIONS, MAX_EVAL_BUDGET_MS, WEDGED_DEFER_BUDGET_MS, } from "./version.js";
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
export { TimeoutError };
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by `t.skip()`. It unwinds the spec like any throw, but the runtime
|
|
19
|
+
* grades it `skipped`, never `failed` — and not `xfail` under `spec.fail`.
|
|
20
|
+
* Internal: a spec never needs to name it.
|
|
21
|
+
*/
|
|
22
|
+
export class SkipSignal extends Error {
|
|
23
|
+
constructor(reason) {
|
|
24
|
+
super(`skipped: ${reason}`);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
this.name = "SkipSignal";
|
|
27
|
+
this.code = "E_SKIPPED";
|
|
12
28
|
}
|
|
13
29
|
}
|
|
14
30
|
export class LiveFixture {
|
|
15
|
-
|
|
31
|
+
traceMeta() {
|
|
32
|
+
return { sequence: ++this.eventSequence, at_ms: Math.max(0, Date.now() - this.startedAt) };
|
|
33
|
+
}
|
|
34
|
+
constructor(specId, rawApp, host, args, automation, specBudgetMs = DEFAULT_SPEC_TIMEOUT_MS, redactor, appId, traceOriginMs) {
|
|
16
35
|
this.specId = specId;
|
|
17
36
|
this.host = host;
|
|
18
|
-
this.automation = automation;
|
|
19
37
|
this.specBudgetMs = specBudgetMs;
|
|
38
|
+
this.redactor = redactor;
|
|
20
39
|
this.steps = [];
|
|
21
40
|
/**
|
|
22
41
|
* `lx.*` members this spec's evals actually reached. Collected so the report
|
|
@@ -26,54 +45,241 @@ export class LiveFixture {
|
|
|
26
45
|
this.assertions = [];
|
|
27
46
|
this.attachments = [];
|
|
28
47
|
this.defers = [];
|
|
48
|
+
this.closed = false;
|
|
29
49
|
this.aborted = false;
|
|
30
50
|
this.abortError = null;
|
|
31
51
|
this.actionSilence = 0;
|
|
32
52
|
this.actionCount = 0;
|
|
53
|
+
this.traceTruncated = false;
|
|
54
|
+
this.eventSequence = 0;
|
|
33
55
|
/** Actions still in flight, so an abort can mark them instead of leaving
|
|
34
56
|
* them at their optimistic default. */
|
|
35
57
|
this.openActions = new Set();
|
|
58
|
+
/** Fixture calls that have started and not settled; see `track`. */
|
|
59
|
+
this.inFlight = new Set();
|
|
36
60
|
this.cleanupUntil = 0;
|
|
37
61
|
this.cleanupActive = false;
|
|
62
|
+
/**
|
|
63
|
+
* The spec reached an app page (a locator, view, nav or page-bound Logic
|
|
64
|
+
* call). A failure names the current page only then: a unit spec that
|
|
65
|
+
* never touched one is not "on" whatever page the app happens to show.
|
|
66
|
+
*/
|
|
67
|
+
this.usedPage = false;
|
|
38
68
|
this.failurePhase = null;
|
|
69
|
+
/** Locator assertions that timed out, by error: the page instance they were bound to. */
|
|
70
|
+
this.locatorFailures = new WeakMap();
|
|
71
|
+
/** Instance ids `t.app.page()` bound, so a trace detail's `#id` is not read from a CSS selector. */
|
|
72
|
+
this.boundPages = new Set();
|
|
39
73
|
this.stepStack = [];
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
this.stepStarted = new WeakMap();
|
|
75
|
+
/** Every app a fixture app reaches; a profile switch re-selects them. */
|
|
76
|
+
this.appRefs = [];
|
|
77
|
+
this.dialogWatches = new Map();
|
|
78
|
+
this.dialogInitializers = new Map();
|
|
79
|
+
this.networkScope = new NetworkScope();
|
|
80
|
+
/** The scenario this spec installed; removed when it ends. */
|
|
81
|
+
this.scenarioScope = new ScenarioScope();
|
|
82
|
+
/** Test clocks this spec installed; uninstalled when it ends. */
|
|
83
|
+
this.clockScope = new ClockScope();
|
|
84
|
+
this.pinned = { appid: appId, driver: rawApp };
|
|
85
|
+
this.appRefs.push(this.pinned);
|
|
86
|
+
this.hostAutomation = automation;
|
|
87
|
+
this.startedAt = traceOriginMs ?? Date.now();
|
|
88
|
+
this.argValues = args;
|
|
89
|
+
this.specDeadline = Date.now() + specBudgetMs;
|
|
90
|
+
let resolveDialogFailure;
|
|
91
|
+
this.dialogFailure = new Promise((resolve) => { resolveDialogFailure = resolve; });
|
|
92
|
+
this.resolveDialogFailure = resolveDialogFailure;
|
|
93
|
+
setExpectScope({
|
|
94
|
+
note: (entry) => this.noteAssertion(entry),
|
|
95
|
+
locator: (locator, message) => this.locatorMatchers(locator, false, message),
|
|
96
|
+
poll: (read, options) => this.pollMatchers(read, options, false, "expect.poll"),
|
|
97
|
+
});
|
|
98
|
+
const root = guardObject(automation, this, "", ["lxapp", ...HOST_TIERS]);
|
|
99
|
+
this.automation = new Proxy(root, {
|
|
100
|
+
get: (target, prop) => prop === "lxapp"
|
|
101
|
+
? (appId) => {
|
|
102
|
+
this.assertRunnable();
|
|
103
|
+
return this.appOf(appId === undefined ? this.pinned : this.refFor(appId));
|
|
104
|
+
}
|
|
105
|
+
// Reading a host tier never throws, even on a host without it; each
|
|
106
|
+
// call resolves it and rejects there instead.
|
|
107
|
+
: HOST_TIERS.includes(prop)
|
|
108
|
+
? lazyDriver(() => ({ owner: automation, value: Reflect.get(automation, prop) }), this, `${String(prop)}.`)
|
|
109
|
+
: Reflect.get(target, prop),
|
|
110
|
+
});
|
|
46
111
|
}
|
|
47
112
|
get app() {
|
|
48
|
-
return this.
|
|
113
|
+
return this.appOf(this.pinned);
|
|
114
|
+
}
|
|
115
|
+
get scenario() {
|
|
116
|
+
return { use: async (definition, options) => {
|
|
117
|
+
const app = options?.app;
|
|
118
|
+
if (app === undefined) {
|
|
119
|
+
return installScenario(() => this.pinned.driver, this, this.scenarioScope, definition, options?.variant);
|
|
120
|
+
}
|
|
121
|
+
if (typeof app !== "string" || app.trim() === "") {
|
|
122
|
+
throw new TypeError("t.scenario.use { app } must be a non-empty app id");
|
|
123
|
+
}
|
|
124
|
+
// Function rules go to the run's companion, which no app scopes.
|
|
125
|
+
const appScoped = "t.scenario.use { app } takes a scenario of http rules only; function rules are not app-scoped";
|
|
126
|
+
if (hasFunctionRules(definition, options?.variant))
|
|
127
|
+
throw new TypeError(appScoped);
|
|
128
|
+
const ref = this.refFor(app);
|
|
129
|
+
const scenario = await installScenario(() => ref.driver, this, this.scenarioScope, definition, options?.variant);
|
|
130
|
+
if (scenario.rules.some((rule) => rule.kind === "function")) {
|
|
131
|
+
await scenario.remove();
|
|
132
|
+
throw new TypeError(appScoped);
|
|
133
|
+
}
|
|
134
|
+
return scenario;
|
|
135
|
+
} };
|
|
49
136
|
}
|
|
50
137
|
get raw() {
|
|
51
|
-
return this.
|
|
138
|
+
return this.pinned.driver;
|
|
139
|
+
}
|
|
140
|
+
async watchPrimaryDialogs() {
|
|
141
|
+
await this.ensureDialogWatch(this.pinned);
|
|
142
|
+
}
|
|
143
|
+
ensureDialogWatch(ref) {
|
|
144
|
+
const pending = this.dialogInitializers.get(ref);
|
|
145
|
+
if (pending)
|
|
146
|
+
return pending;
|
|
147
|
+
const task = this.startDialogWatch(ref).finally(() => { this.dialogInitializers.delete(ref); });
|
|
148
|
+
this.dialogInitializers.set(ref, task);
|
|
149
|
+
return task;
|
|
150
|
+
}
|
|
151
|
+
async startDialogWatch(ref) {
|
|
152
|
+
const appid = ref.appid ?? (await new ActionDeadline(DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom())
|
|
153
|
+
.call("dialog watch app info", () => ref.driver.info(), () => "starting dialog watch")).appId;
|
|
154
|
+
ref.appid = appid;
|
|
155
|
+
if (this.dialogWatches.has(appid))
|
|
156
|
+
return;
|
|
157
|
+
const watch = watchDialogs(ref.driver);
|
|
158
|
+
// Only another app's dialog needs its app named.
|
|
159
|
+
const label = (message) => ref === this.pinned ? message : `${appid}: ${message}`;
|
|
160
|
+
this.dialogWatches.set(appid, { ref, watch, label });
|
|
161
|
+
if (watch)
|
|
162
|
+
void watch.unanswered.then((message) => this.resolveDialogFailure(label(message)));
|
|
163
|
+
}
|
|
164
|
+
async endDialogWatches() {
|
|
165
|
+
const unused = [];
|
|
166
|
+
const failures = [];
|
|
167
|
+
for (const [appid, entry] of this.dialogWatches) {
|
|
168
|
+
try {
|
|
169
|
+
const message = await entry.watch?.end(entry.ref.driver);
|
|
170
|
+
if (message)
|
|
171
|
+
unused.push(entry.label(message));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
failures.push(entry.label(String(error)));
|
|
175
|
+
}
|
|
176
|
+
this.dialogWatches.delete(appid);
|
|
177
|
+
}
|
|
178
|
+
return { unused, failures };
|
|
179
|
+
}
|
|
180
|
+
/** One fixture app per reached app, so a saved handle stays the same object. */
|
|
181
|
+
appOf(ref) {
|
|
182
|
+
return ref.app ?? (ref.app = this.wrapApp(ref));
|
|
183
|
+
}
|
|
184
|
+
refFor(appid) {
|
|
185
|
+
return this.appRefs.find((ref) => ref.appid === appid) ??
|
|
186
|
+
this.trackRef({ appid, driver: this.hostAutomation.lxapp(appid) });
|
|
187
|
+
}
|
|
188
|
+
trackRef(ref) {
|
|
189
|
+
this.appRefs.push(ref);
|
|
190
|
+
return ref;
|
|
191
|
+
}
|
|
192
|
+
get openapi() {
|
|
193
|
+
const index = activeOpenApi();
|
|
194
|
+
if (!index)
|
|
195
|
+
return undefined;
|
|
196
|
+
return {
|
|
197
|
+
documents: index.documents.map((doc) => ({
|
|
198
|
+
name: doc.name,
|
|
199
|
+
version: doc.version,
|
|
200
|
+
...(doc.title ? { title: doc.title } : {}),
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** `t.app.profile`: it re-selects the app after a switch. */
|
|
205
|
+
profileFixture(ref) {
|
|
206
|
+
return {
|
|
207
|
+
checkpoint: () => this.act("profile.checkpoint", "", () => this.reopening(ref, async (driver) => ({ id: await driver.profile.checkpoint() }))),
|
|
208
|
+
restore: (checkpoint, options) => {
|
|
209
|
+
const id = checkpointId(checkpoint, "t.app.profile.restore");
|
|
210
|
+
return this.act("profile.restore", options?.keep?.length ? `${id} keep ${options.keep.join(",")}` : id, () => this.reopening(ref, (driver) => options?.keep?.length ? driver.profile.restore(id, { keep: [...options.keep] }) : driver.profile.restore(id)));
|
|
211
|
+
},
|
|
212
|
+
drop: (checkpoint) => {
|
|
213
|
+
const id = checkpointId(checkpoint, "t.app.profile.drop");
|
|
214
|
+
return this.act("profile.drop", id, async () => { await ref.driver.profile.drop(id); });
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A profile switch closes the app and reopens it as a new instance, which
|
|
220
|
+
* the old driver no longer reaches: select the same lxapp again after it,
|
|
221
|
+
* for every fixture app that reaches it. Fixture apps read `ref.driver` on
|
|
222
|
+
* each call, so one saved before the switch follows the reopened app.
|
|
223
|
+
*/
|
|
224
|
+
async reopening(ref, op) {
|
|
225
|
+
const appid = ref.appid ?? (await ref.driver.info()).appId;
|
|
226
|
+
ref.appid = appid;
|
|
227
|
+
// The no-arg alias is `pinned`; an explicit same-app alias can still be
|
|
228
|
+
// reached before its id was known. A closed unrelated app must not block
|
|
229
|
+
// this profile switch just because its handle was read earlier.
|
|
230
|
+
if (this.pinned.appid === undefined && ref !== this.pinned) {
|
|
231
|
+
try {
|
|
232
|
+
this.pinned.appid = (await this.pinned.driver.info()).appId;
|
|
233
|
+
}
|
|
234
|
+
catch { /* unavailable alias */ }
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return await op(ref.driver);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
for (const reached of this.appRefs) {
|
|
241
|
+
if (reached.appid === appid)
|
|
242
|
+
reached.driver = this.hostAutomation.lxapp(appid);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
52
245
|
}
|
|
53
246
|
step(name, body) {
|
|
54
|
-
return this.
|
|
247
|
+
return this.runStep(name, body);
|
|
248
|
+
}
|
|
249
|
+
runStep(name, body, parent) {
|
|
250
|
+
return this.track("t.step", name, () => this.guard(async () => {
|
|
251
|
+
const active = this.stepStack[this.stepStack.length - 1];
|
|
252
|
+
if (active !== parent) {
|
|
253
|
+
const conflict = new Error("Overlapping t.step calls cannot share an implicit step owner. Await top-level steps sequentially; nest with the callback scope: t.step('outer', async (step) => step.step('inner', ...)).");
|
|
254
|
+
this.stepConflict ?? (this.stepConflict = conflict);
|
|
255
|
+
throw conflict;
|
|
256
|
+
}
|
|
55
257
|
const record = {
|
|
56
258
|
name,
|
|
57
|
-
|
|
259
|
+
...this.traceMeta(),
|
|
260
|
+
path: parent ? `${parent.path} > ${name}` : name,
|
|
58
261
|
status: "passed",
|
|
59
262
|
duration_ms: 0,
|
|
60
263
|
steps: [],
|
|
61
264
|
attachments: [],
|
|
62
265
|
assertions: [],
|
|
63
266
|
};
|
|
64
|
-
const parent = this.stepStack[this.stepStack.length - 1];
|
|
65
267
|
(parent ? parent.steps : this.steps).push(record);
|
|
66
268
|
this.stepStack.push(record);
|
|
67
|
-
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
this.stepStarted.set(record, started);
|
|
271
|
+
await this.emitTrace({
|
|
68
272
|
type: "step_started",
|
|
69
273
|
name,
|
|
70
274
|
path: record.path,
|
|
71
275
|
});
|
|
72
|
-
const started = Date.now();
|
|
73
276
|
try {
|
|
74
|
-
const
|
|
277
|
+
const scope = {
|
|
278
|
+
step: (childName, childBody) => this.runStep(childName, childBody, record),
|
|
279
|
+
};
|
|
280
|
+
const result = await body(scope);
|
|
75
281
|
record.duration_ms = Date.now() - started;
|
|
76
|
-
await this.
|
|
282
|
+
await this.emitTrace({
|
|
77
283
|
type: "step_finished",
|
|
78
284
|
name,
|
|
79
285
|
path: record.path,
|
|
@@ -84,10 +290,21 @@ export class LiveFixture {
|
|
|
84
290
|
}
|
|
85
291
|
catch (error) {
|
|
86
292
|
record.duration_ms = Date.now() - started;
|
|
293
|
+
if (error instanceof SkipSignal) {
|
|
294
|
+
record.status = "skipped";
|
|
295
|
+
await this.emitTrace({
|
|
296
|
+
type: "step_finished",
|
|
297
|
+
name,
|
|
298
|
+
path: record.path,
|
|
299
|
+
status: record.status,
|
|
300
|
+
duration_ms: record.duration_ms,
|
|
301
|
+
});
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
87
304
|
record.status = error instanceof TimeoutError ? "timeout" : "failed";
|
|
88
305
|
record.error = toReportError(error, record.path);
|
|
89
306
|
this.lastStepPath = record.path;
|
|
90
|
-
await this.
|
|
307
|
+
await this.emitTrace({
|
|
91
308
|
type: "step_finished",
|
|
92
309
|
name,
|
|
93
310
|
path: record.path,
|
|
@@ -98,14 +315,12 @@ export class LiveFixture {
|
|
|
98
315
|
throw error;
|
|
99
316
|
}
|
|
100
317
|
finally {
|
|
101
|
-
this.
|
|
318
|
+
this.stepStarted.delete(record);
|
|
319
|
+
const index = this.stepStack.lastIndexOf(record);
|
|
320
|
+
if (index >= 0)
|
|
321
|
+
this.stepStack.splice(index, 1);
|
|
102
322
|
}
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
get expect() {
|
|
106
|
-
const fn = ((locator) => this.locatorMatchers(locator, false));
|
|
107
|
-
fn.poll = (read, options) => this.pollMatchers(read, options, false);
|
|
108
|
-
return fn;
|
|
323
|
+
}));
|
|
109
324
|
}
|
|
110
325
|
async reject(operation, expected = {}) {
|
|
111
326
|
return this.guard(async () => {
|
|
@@ -135,7 +350,7 @@ export class LiveFixture {
|
|
|
135
350
|
].filter(Boolean).join("\n"));
|
|
136
351
|
}
|
|
137
352
|
const record = received;
|
|
138
|
-
if (expected.code !== undefined &&
|
|
353
|
+
if (expected.code !== undefined && !matchesErrorCode(received, expected.code)) {
|
|
139
354
|
this.noteAssertion({
|
|
140
355
|
matcher: "reject",
|
|
141
356
|
expected: formatValue(expected.code),
|
|
@@ -151,10 +366,10 @@ export class LiveFixture {
|
|
|
151
366
|
].filter(Boolean).join("\n"));
|
|
152
367
|
}
|
|
153
368
|
if (typeof expected.message === "string") {
|
|
154
|
-
|
|
369
|
+
check(String(record.message)).toContain(expected.message);
|
|
155
370
|
}
|
|
156
371
|
if (expected.message instanceof RegExp) {
|
|
157
|
-
|
|
372
|
+
check(String(record.message)).toMatch(expected.message);
|
|
158
373
|
}
|
|
159
374
|
this.noteAssertion({
|
|
160
375
|
matcher: "reject",
|
|
@@ -168,12 +383,114 @@ export class LiveFixture {
|
|
|
168
383
|
defer(cleanup) {
|
|
169
384
|
this.defers.push(cleanup);
|
|
170
385
|
}
|
|
386
|
+
arg(name, options = {}) {
|
|
387
|
+
const value = this.argValues[name];
|
|
388
|
+
if (value !== undefined)
|
|
389
|
+
return value;
|
|
390
|
+
if (options.default !== undefined)
|
|
391
|
+
return options.default;
|
|
392
|
+
if (options.required === false)
|
|
393
|
+
return undefined;
|
|
394
|
+
// Keys are case-sensitive: `LXDEV_SECRET_PASSWORD` is `PASSWORD`.
|
|
395
|
+
const other = Object.keys(this.argValues).find((key) => key !== name && key.toLowerCase() === name.toLowerCase());
|
|
396
|
+
const hint = other === undefined
|
|
397
|
+
? ""
|
|
398
|
+
: `; "${other}" was given, and arg keys are case-sensitive ` +
|
|
399
|
+
`(LXDEV_ARG_<KEY> / LXDEV_SECRET_<KEY> keep the case of <KEY>)`;
|
|
400
|
+
throw new Error(`Missing test arg "${name}": pass --arg ${name}=<value> ` +
|
|
401
|
+
`(or --secret-arg ${name}=<value>) to lxdev test${hint}`);
|
|
402
|
+
}
|
|
403
|
+
waitFor(read, options = {}) {
|
|
404
|
+
const location = callerLocation();
|
|
405
|
+
if (typeof options === "function") {
|
|
406
|
+
throw new TypeError("t.waitFor(read, { until }) takes its acceptance test as `until`, not as a second argument");
|
|
407
|
+
}
|
|
408
|
+
const accept = options.until ?? Boolean;
|
|
409
|
+
const requested = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
410
|
+
const interval = options.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
411
|
+
if (!Number.isFinite(requested) || requested <= 0 || !Number.isFinite(interval) || interval <= 0) {
|
|
412
|
+
throw new TypeError("t.waitFor timeout and interval must be positive finite numbers");
|
|
413
|
+
}
|
|
414
|
+
const retryIf = options.retryIf ?? isRetryableReadError;
|
|
415
|
+
const detail = truncate(read.name || functionDetail(read), 80);
|
|
416
|
+
return this.act("waitFor", detail, async () => {
|
|
417
|
+
const deadline = new ActionDeadline(requested, this.budgetRoom());
|
|
418
|
+
let attempts = 0;
|
|
419
|
+
let hasValue = false;
|
|
420
|
+
let lastValue;
|
|
421
|
+
let lastError;
|
|
422
|
+
this.silenceActions();
|
|
423
|
+
try {
|
|
424
|
+
for (;;) {
|
|
425
|
+
if (deadline.expired())
|
|
426
|
+
break;
|
|
427
|
+
attempts += 1;
|
|
428
|
+
const before = new Set(this.inFlight);
|
|
429
|
+
try {
|
|
430
|
+
const value = await deadline.call("t.waitFor read", read, () => `${detail} at ${displayLocation(location.file, location.line, location.column)}`);
|
|
431
|
+
if (deadline.expired())
|
|
432
|
+
break;
|
|
433
|
+
lastValue = value;
|
|
434
|
+
hasValue = true;
|
|
435
|
+
lastError = undefined;
|
|
436
|
+
if (accept(value))
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof SkipSignal || this.aborted)
|
|
441
|
+
throw error;
|
|
442
|
+
if (error instanceof TimeoutError && deadline.expired()) {
|
|
443
|
+
for (const call of this.inFlight)
|
|
444
|
+
if (!before.has(call))
|
|
445
|
+
call.detached = true;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
if (!retryIf(error))
|
|
449
|
+
throw error;
|
|
450
|
+
lastError = error;
|
|
451
|
+
}
|
|
452
|
+
const pause = Math.min(interval, deadline.remaining());
|
|
453
|
+
if (pause <= 0)
|
|
454
|
+
break;
|
|
455
|
+
await sleep(pause);
|
|
456
|
+
if (this.aborted && this.abortError)
|
|
457
|
+
throw this.abortError;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
this.resumeActions();
|
|
462
|
+
}
|
|
463
|
+
const last = lastError !== undefined
|
|
464
|
+
? `Last error: ${errorLine(lastError)}`
|
|
465
|
+
: hasValue
|
|
466
|
+
? `Last value: ${formatValue(lastValue)}${accept === Boolean ? " (waiting for a truthy value)" : " (rejected by until)"}`
|
|
467
|
+
: "No read completed.";
|
|
468
|
+
throw new TimeoutError([
|
|
469
|
+
`t.waitFor timed out after ${deadline.elapsed()}ms (${attempts} ${attempts === 1 ? "read" : "reads"}).`,
|
|
470
|
+
last,
|
|
471
|
+
deadline.clampNote(),
|
|
472
|
+
`at ${displayLocation(location.file, location.line, location.column)}`,
|
|
473
|
+
this.stepPathLine(),
|
|
474
|
+
].filter(Boolean).join("\n"));
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
skip(reason) {
|
|
478
|
+
// Cleanup runs after the verdict; a skip there cannot mean anything.
|
|
479
|
+
if (this.cleanupActive)
|
|
480
|
+
throw new Error("t.skip cannot be called during cleanup (t.defer or afterEach)");
|
|
481
|
+
const text = typeof reason === "string" && reason.trim().length > 0 ? reason : "skipped at runtime";
|
|
482
|
+
this.skipReason ?? (this.skipReason = text);
|
|
483
|
+
throw new SkipSignal(text);
|
|
484
|
+
}
|
|
171
485
|
async attach(name, data) {
|
|
172
486
|
await this.guard(async () => {
|
|
173
487
|
await this.attachRaw(name, data);
|
|
174
488
|
});
|
|
175
489
|
}
|
|
176
|
-
async attachRaw(name,
|
|
490
|
+
async attachRaw(name, input, purpose) {
|
|
491
|
+
// Declared secrets are masked in the data itself, before it is encoded
|
|
492
|
+
// or previewed, so neither the file nor the report carries them.
|
|
493
|
+
const data = this.redactor ? this.redactor.attachment(input) : input;
|
|
177
494
|
const payload = encodeAttachPayload(data);
|
|
178
495
|
if (typeof data === "object" && data && "base64" in data && !("mimeType" in data)) {
|
|
179
496
|
if (name.endsWith(".png"))
|
|
@@ -189,18 +506,26 @@ export class LiveFixture {
|
|
|
189
506
|
else if (isPreviewable(payload.mimeType)) {
|
|
190
507
|
rememberInline(this.specId, name, { text: previewText(data, payload) });
|
|
191
508
|
}
|
|
192
|
-
const ref = { name, path, mimeType: payload.mimeType };
|
|
193
509
|
const current = this.stepStack[this.stepStack.length - 1];
|
|
510
|
+
const ref = { name, path, mimeType: payload.mimeType, ...this.traceMeta(),
|
|
511
|
+
...(current ? { step: current.path } : {}), ...(purpose ? { purpose } : {}) };
|
|
194
512
|
(current ? current.attachments : this.attachments).push(ref);
|
|
195
513
|
return ref;
|
|
196
514
|
}
|
|
197
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Stop the body: its fixture calls reject with `reason` from now on. A
|
|
517
|
+
* timeout marks the actions still open as timed out; a failure (an
|
|
518
|
+
* unanswered dialog) as failed.
|
|
519
|
+
*/
|
|
520
|
+
abort(reason, as = "timeout") {
|
|
198
521
|
this.aborted = true;
|
|
199
522
|
this.abortError = reason;
|
|
200
|
-
this.failurePhase = "timeout";
|
|
523
|
+
this.failurePhase = as === "timeout" ? "timeout" : "body";
|
|
524
|
+
this.finishOpenSteps(reason, as);
|
|
201
525
|
for (const record of this.openActions) {
|
|
202
|
-
record.status =
|
|
526
|
+
record.status = as;
|
|
203
527
|
record.error = toReportError(reason, record.path);
|
|
528
|
+
this.noteFailedAction(record.name, record.detail, reason);
|
|
204
529
|
}
|
|
205
530
|
this.openActions.clear();
|
|
206
531
|
}
|
|
@@ -216,17 +541,108 @@ export class LiveFixture {
|
|
|
216
541
|
* trace of what it did. Retry loops silence themselves: a five-second poll
|
|
217
542
|
* would otherwise bury the report in a hundred identical rows.
|
|
218
543
|
*/
|
|
219
|
-
|
|
544
|
+
act(name, detail, op) {
|
|
545
|
+
if (PAGE_ACTION.test(name))
|
|
546
|
+
this.usedPage = true;
|
|
547
|
+
return this.track(name, detail, () => this.recordAct(name, detail, async () => {
|
|
548
|
+
try {
|
|
549
|
+
return await op();
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
553
|
+
if (code === "E_EVAL_TIMEOUT" && (name === "logic.eval" || name === "page.data" || name === "view.eval")) {
|
|
554
|
+
this.remoteTimeoutKind ?? (this.remoteTimeoutKind = name === "view.eval" ? "View eval" : "Logic eval");
|
|
555
|
+
}
|
|
556
|
+
else if (code === "E_AUTOMATION_TIMEOUT" && name === "page.action") {
|
|
557
|
+
this.remoteTimeoutKind ?? (this.remoteTimeoutKind = "page action");
|
|
558
|
+
}
|
|
559
|
+
throw asFixtureTimeout(error);
|
|
560
|
+
}
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Run one fixture call while it counts as in flight, so a body that
|
|
565
|
+
* returns without awaiting it can be named (with where it was started),
|
|
566
|
+
* and a timed-out body can say what it still awaits.
|
|
567
|
+
*/
|
|
568
|
+
track(name, detail, op, label) {
|
|
569
|
+
const call = { name, detail, label, started: Date.now(), origin: new Error() };
|
|
570
|
+
this.inFlight.add(call);
|
|
571
|
+
const promise = (async () => {
|
|
572
|
+
try {
|
|
573
|
+
return await op();
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
this.inFlight.delete(call);
|
|
577
|
+
}
|
|
578
|
+
})();
|
|
579
|
+
call.promise = promise;
|
|
580
|
+
return promise;
|
|
581
|
+
}
|
|
582
|
+
/** A read `t.waitFor` gave up on is still running in the app. */
|
|
583
|
+
detachedInFlight() {
|
|
584
|
+
return [...this.inFlight].some((entry) => entry.detached);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* The fixture calls still running, as a body that returned without
|
|
588
|
+
* awaiting them left them: what each is and where the spec started it.
|
|
589
|
+
*/
|
|
590
|
+
unsettledCalls() {
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
return [...this.inFlight].filter((entry) => !entry.detached).map((entry) => {
|
|
593
|
+
const frame = resolveOrigin(parseFrames(entry.origin.stack));
|
|
594
|
+
return {
|
|
595
|
+
call: entry.label ?? (entry.detail ? `${entry.name} ${entry.detail}` : entry.name),
|
|
596
|
+
at: displayLocation(frame.file, frame.line, frame.column),
|
|
597
|
+
ageMs: now - entry.started,
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Stop the calls a returned body left running: each fails at its next
|
|
603
|
+
* fixture guard or retry, and its rejection is marked handled (nobody
|
|
604
|
+
* awaits it). Resolves `true` once they settled, `false` if one is still
|
|
605
|
+
* waiting on the app after `ms`. The fixture is usable again afterwards,
|
|
606
|
+
* for cleanup.
|
|
607
|
+
*/
|
|
608
|
+
async stopUnsettled(reason, ms) {
|
|
609
|
+
for (const entry of this.inFlight)
|
|
610
|
+
entry.promise?.catch(() => { });
|
|
611
|
+
this.aborted = true;
|
|
612
|
+
this.abortError = reason;
|
|
613
|
+
const deadline = Date.now() + ms;
|
|
614
|
+
while (this.inFlight.size > 0 && Date.now() < deadline)
|
|
615
|
+
await sleep(10);
|
|
616
|
+
this.aborted = false;
|
|
617
|
+
this.abortError = null;
|
|
618
|
+
return this.inFlight.size === 0;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Fixture calls that have not returned, e.g. a hung `t.app.logic.eval` a
|
|
622
|
+
* timed-out body still awaits. Unlike the trace rows, this includes calls
|
|
623
|
+
* made from silenced retry loops.
|
|
624
|
+
*/
|
|
625
|
+
pendingCalls() {
|
|
626
|
+
return [...this.inFlight].map((call) => ({
|
|
627
|
+
kind: /^(?:logic|view)\.eval$|^page\.data$/.test(call.name) ? "eval" : "action",
|
|
628
|
+
detail: call.detail ? `${call.name} ${call.detail}` : call.name,
|
|
629
|
+
owner: this.specId,
|
|
630
|
+
at_ms: call.started - this.startedAt,
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
async recordAct(name, detail, op) {
|
|
220
634
|
if (this.actionSilence > 0)
|
|
221
635
|
return this.guard(op);
|
|
222
636
|
// Past the cap, keep recording failures: the action that finally breaks is
|
|
223
637
|
// the one row worth having, and dropping it leaves nothing pointing at it.
|
|
224
638
|
if (this.actionCount >= MAX_ACTIONS) {
|
|
639
|
+
this.traceTruncated = true;
|
|
225
640
|
try {
|
|
226
641
|
return await this.guard(op);
|
|
227
642
|
}
|
|
228
643
|
catch (error) {
|
|
229
644
|
this.recordFailedAction(name, detail, error, 0);
|
|
645
|
+
this.noteFailedAction(name, detail, error);
|
|
230
646
|
throw error;
|
|
231
647
|
}
|
|
232
648
|
}
|
|
@@ -238,7 +654,8 @@ export class LiveFixture {
|
|
|
238
654
|
if (previous?.kind === "action" &&
|
|
239
655
|
previous.status === "passed" &&
|
|
240
656
|
previous.name === name &&
|
|
241
|
-
previous.detail === detail
|
|
657
|
+
previous.detail === detail &&
|
|
658
|
+
previous.sequence === this.eventSequence) {
|
|
242
659
|
const started = Date.now();
|
|
243
660
|
try {
|
|
244
661
|
const result = await this.guard(op);
|
|
@@ -250,6 +667,7 @@ export class LiveFixture {
|
|
|
250
667
|
// A failure is its own row: it is the one attempt worth reading.
|
|
251
668
|
const failed = {
|
|
252
669
|
name,
|
|
670
|
+
...this.traceMeta(),
|
|
253
671
|
detail,
|
|
254
672
|
kind: "action",
|
|
255
673
|
path: previous.path,
|
|
@@ -261,12 +679,14 @@ export class LiveFixture {
|
|
|
261
679
|
error: toReportError(error, previous.path),
|
|
262
680
|
};
|
|
263
681
|
siblings.push(failed);
|
|
682
|
+
this.noteFailedAction(name, detail, error);
|
|
264
683
|
throw error;
|
|
265
684
|
}
|
|
266
685
|
}
|
|
267
686
|
this.actionCount += 1;
|
|
268
687
|
const record = {
|
|
269
688
|
name,
|
|
689
|
+
...this.traceMeta(),
|
|
270
690
|
detail,
|
|
271
691
|
kind: "action",
|
|
272
692
|
path: [...this.stepStack.map((step) => step.name), name].join(" > "),
|
|
@@ -282,6 +702,7 @@ export class LiveFixture {
|
|
|
282
702
|
// body, so a record left at its optimistic default would serialise as an
|
|
283
703
|
// instant success — the hung call rendered as the fastest one in the trace.
|
|
284
704
|
this.openActions.add(record);
|
|
705
|
+
await this.emitTrace({ type: "step_started", name, path: record.path });
|
|
285
706
|
try {
|
|
286
707
|
const result = await this.guard(op);
|
|
287
708
|
record.duration_ms = Date.now() - started;
|
|
@@ -293,14 +714,64 @@ export class LiveFixture {
|
|
|
293
714
|
record.status = error instanceof TimeoutError ? "timeout" : "failed";
|
|
294
715
|
record.error = toReportError(error, record.path);
|
|
295
716
|
this.openActions.delete(record);
|
|
717
|
+
this.noteFailedAction(name, detail, error);
|
|
296
718
|
throw error;
|
|
297
719
|
}
|
|
720
|
+
finally {
|
|
721
|
+
await this.emitTrace({ type: "step_finished", name, path: record.path,
|
|
722
|
+
status: record.status, duration_ms: record.duration_ms, error: record.error });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* A trace event is progress for lxdev, not part of the spec: one the
|
|
727
|
+
* transport drops is sent once more and then given up, and never fails the
|
|
728
|
+
* action it describes. The report is built from the fixture's own records.
|
|
729
|
+
*/
|
|
730
|
+
async emitTrace(event) {
|
|
731
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
732
|
+
try {
|
|
733
|
+
await this.host.emit(event);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
// Next attempt, then give up.
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* A note beside the trace. A warning unless `level` is `"info"`: expected
|
|
743
|
+
* housekeeping, such as the timers the spec's own clock dropped, which
|
|
744
|
+
* lxdev shows only with `--verbose`.
|
|
745
|
+
*/
|
|
746
|
+
async diagnostic(phase, message, level) {
|
|
747
|
+
await this.emitTrace({ type: "diagnostic", phase, message, ...(level ? { level } : {}) });
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* An idempotent driver read, retried when the transport between the test
|
|
751
|
+
* runtime and the app dropped it. Never used for input or other calls with
|
|
752
|
+
* side effects: those may already have landed.
|
|
753
|
+
*/
|
|
754
|
+
async readRetrying(read) {
|
|
755
|
+
for (let attempt = 0;; attempt += 1) {
|
|
756
|
+
try {
|
|
757
|
+
return await read();
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
if (attempt >= TRANSPORT_RETRIES || !isTransientTransportError(error) || this.aborted)
|
|
761
|
+
throw error;
|
|
762
|
+
await sleep(TRANSPORT_RETRY_DELAY_MS * (attempt + 1));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
noteFailedAction(name, detail, error) {
|
|
767
|
+
this.failedAction = { action: detail ? `${name} ${detail}` : name, error };
|
|
298
768
|
}
|
|
299
769
|
recordFailedAction(name, detail, error, duration) {
|
|
300
770
|
const parent = this.stepStack[this.stepStack.length - 1];
|
|
301
771
|
const path = [...this.stepStack.map((step) => step.name), name].join(" > ");
|
|
302
772
|
(parent ? parent.steps : this.steps).push({
|
|
303
773
|
name,
|
|
774
|
+
...this.traceMeta(),
|
|
304
775
|
detail,
|
|
305
776
|
kind: "action",
|
|
306
777
|
path,
|
|
@@ -324,6 +795,18 @@ export class LiveFixture {
|
|
|
324
795
|
this.assertRunnable();
|
|
325
796
|
return result;
|
|
326
797
|
}
|
|
798
|
+
/**
|
|
799
|
+
* Milliseconds an action or assertion may still take. Short of the spec's
|
|
800
|
+
* own deadline by a margin, so the action fails first and names its step
|
|
801
|
+
* instead of losing the race to the anonymous spec timeout. During cleanup
|
|
802
|
+
* the cleanup budget bounds it instead.
|
|
803
|
+
*/
|
|
804
|
+
budgetRoom() {
|
|
805
|
+
if (this.cleanupActive)
|
|
806
|
+
return this.cleanupUntil > 0 ? this.cleanupUntil - Date.now() : Number.POSITIVE_INFINITY;
|
|
807
|
+
const margin = Math.min(250, Math.floor(this.specBudgetMs / 20));
|
|
808
|
+
return this.specDeadline - margin - Date.now();
|
|
809
|
+
}
|
|
327
810
|
currentStepPath() {
|
|
328
811
|
const current = this.stepStack[this.stepStack.length - 1];
|
|
329
812
|
return current?.path ?? this.lastStepPath;
|
|
@@ -331,12 +814,51 @@ export class LiveFixture {
|
|
|
331
814
|
noteAssertion(entry) {
|
|
332
815
|
const record = {
|
|
333
816
|
...entry,
|
|
817
|
+
...this.traceMeta(),
|
|
334
818
|
step: this.currentStepPath(),
|
|
335
819
|
};
|
|
336
820
|
const current = this.stepStack[this.stepStack.length - 1];
|
|
337
821
|
(current ? current.assertions : this.assertions).push(record);
|
|
338
822
|
}
|
|
823
|
+
close() {
|
|
824
|
+
this.closed = true;
|
|
825
|
+
this.finishOpenSteps(new Error("step was still running when the spec ended"), "failed");
|
|
826
|
+
}
|
|
827
|
+
finishOpenSteps(reason, status) {
|
|
828
|
+
for (const record of this.stepStack) {
|
|
829
|
+
if (record.status !== "passed")
|
|
830
|
+
continue;
|
|
831
|
+
record.status = status;
|
|
832
|
+
record.duration_ms = Date.now() - (this.stepStarted.get(record) ?? Date.now());
|
|
833
|
+
record.error = toReportError(reason, record.path);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Remove what the framework installed for this spec — its routes, mock
|
|
838
|
+
* scenario and test clocks — apart from the spec's own cleanup, and
|
|
839
|
+
* whether or not its body settled. Resolves one message per failure; a
|
|
840
|
+
* resource already gone is removed.
|
|
841
|
+
*/
|
|
842
|
+
async reclaim() {
|
|
843
|
+
const failures = [];
|
|
844
|
+
const tasks = [
|
|
845
|
+
() => this.networkScope.reclaim(),
|
|
846
|
+
() => this.scenarioScope.reclaim(),
|
|
847
|
+
() => this.clockScope.reclaim((phase, message, level) => this.diagnostic(phase, message, level)),
|
|
848
|
+
];
|
|
849
|
+
for (const task of tasks) {
|
|
850
|
+
try {
|
|
851
|
+
await task();
|
|
852
|
+
}
|
|
853
|
+
catch (error) {
|
|
854
|
+
failures.push(String(error?.message ?? error));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return failures;
|
|
858
|
+
}
|
|
339
859
|
assertRunnable() {
|
|
860
|
+
if (this.closed)
|
|
861
|
+
throw new Error("Test fixture is closed");
|
|
340
862
|
if (this.cleanupActive) {
|
|
341
863
|
if (this.cleanupUntil > 0 && Date.now() > this.cleanupUntil) {
|
|
342
864
|
throw new TimeoutError("fixture cleanup budget exceeded");
|
|
@@ -350,147 +872,407 @@ export class LiveFixture {
|
|
|
350
872
|
const path = this.currentStepPath();
|
|
351
873
|
return path ? `in step ${JSON.stringify(path)}` : "";
|
|
352
874
|
}
|
|
353
|
-
wrapApp(
|
|
354
|
-
const
|
|
875
|
+
wrapApp(ref) {
|
|
876
|
+
const fixture = this;
|
|
877
|
+
const driver = () => ref.driver;
|
|
878
|
+
const input = lazyDriver(() => ({ owner: driver().page, value: driver().page }), fixture, "window.");
|
|
355
879
|
return {
|
|
356
|
-
page,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
880
|
+
page: (selector, options) => this.bindPage(driver, selector, options),
|
|
881
|
+
view: this.wrapView(() => driver().page, undefined),
|
|
882
|
+
window: {
|
|
883
|
+
get pointer() { return input.pointer; },
|
|
884
|
+
get key() { return input.key; },
|
|
885
|
+
},
|
|
886
|
+
logic: this.wrapLogic(driver),
|
|
887
|
+
nav: this.wrapNav(() => driver().nav),
|
|
888
|
+
// Lazy: the driver is read inside each traced call.
|
|
889
|
+
get network() {
|
|
890
|
+
return wrapNetwork(() => driver().network, fixture, fixture.networkScope);
|
|
891
|
+
},
|
|
892
|
+
get profile() {
|
|
893
|
+
return fixture.profileFixture(ref);
|
|
894
|
+
},
|
|
895
|
+
// Lazy and non-throwing like `network`; spec-scoped: uninstalled when
|
|
896
|
+
// the spec ends.
|
|
897
|
+
get clock() {
|
|
898
|
+
return wrapClock(() => ({ driver: driver().clock, appid: async () => (await driver().info()).appId }), fixture, fixture.clockScope, () => fixture.hostAutomation);
|
|
899
|
+
},
|
|
900
|
+
// Lazy and non-throwing like `clock`; the runner watches the app under
|
|
901
|
+
// test for each spec.
|
|
902
|
+
get dialogs() {
|
|
903
|
+
return wrapDialogs(() => driver().dialogs, fixture, () => fixture.ensureDialogWatch(ref));
|
|
904
|
+
},
|
|
905
|
+
info: () => this.act("app.info", "", () => this.readRetrying(() => driver().info())),
|
|
906
|
+
pages: () => this.act("app.pages", "", () => this.readRetrying(() => driver().pages())),
|
|
907
|
+
surfaceLayout: () => this.act("app.surfaceLayout", "", () => this.readRetrying(() => driver().surfaceLayout())),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Fixture navigation takes the fixture's option names and waits for the
|
|
912
|
+
* landed page's `onReady` unless the caller picks `waitUntil: 'commit'`;
|
|
913
|
+
* reads retry a dropped transport.
|
|
914
|
+
*/
|
|
915
|
+
wrapNav(nav) {
|
|
916
|
+
const land = (verb) => (options) => this.act(`nav.${verb}`, summarise(options), () => nav()[verb](this.navOptions(options, `t.app.nav.${verb}`)));
|
|
917
|
+
const read = (verb, op, detail) => this.act(`nav.${verb}`, summarise(detail), () => this.readRetrying(op));
|
|
918
|
+
return {
|
|
919
|
+
to: land("to"),
|
|
920
|
+
redirect: land("redirect"),
|
|
921
|
+
switchTab: land("switchTab"),
|
|
922
|
+
relaunch: land("relaunch"),
|
|
923
|
+
back: (options) => this.act("nav.back", summarise(options), () => nav().back(this.navOptions(options ?? {}, "t.app.nav.back"))),
|
|
924
|
+
current: () => read("current", () => nav().current()),
|
|
925
|
+
info: (options) => read("info", () => nav().info(options), options),
|
|
926
|
+
stack: () => read("stack", () => nav().stack()),
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
bindPage(driver, selector, options) {
|
|
930
|
+
if (selector !== undefined) {
|
|
931
|
+
const keys = [selector?.name, selector?.instanceId].filter(value => value !== undefined);
|
|
932
|
+
if (keys.length !== 1 || typeof keys[0] !== "string" || keys[0].trim() === "") {
|
|
933
|
+
return Promise.reject(new TypeError("t.app.page(selector): select exactly one non-empty name or instanceId"));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const timeout = options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
937
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
938
|
+
return Promise.reject(new TypeError("t.app.page(selector, { timeout }) takes a positive number of ms"));
|
|
939
|
+
}
|
|
940
|
+
const wanted = selector === undefined ? "the current page" : selector.name !== undefined
|
|
941
|
+
? `page ${JSON.stringify(selector.name)}` : `page instance #${selector.instanceId}`;
|
|
942
|
+
return this.act("page.bind", summarise(selector), async () => {
|
|
943
|
+
// Capture this app driver: reopening the app must not retarget the handle.
|
|
944
|
+
const app = driver();
|
|
945
|
+
const deadline = new ActionDeadline(timeout, this.budgetRoom());
|
|
946
|
+
let info;
|
|
947
|
+
let last = "not open";
|
|
948
|
+
for (;;) {
|
|
949
|
+
try {
|
|
950
|
+
const found = await deadline.call("t.app.page binding read", () => selector
|
|
951
|
+
? app.nav.info({ page: selector.instanceId ?? selector.name })
|
|
952
|
+
: app.nav.current(), () => `waiting for ${wanted}`);
|
|
953
|
+
if (deadline.expired())
|
|
954
|
+
break;
|
|
955
|
+
if (found?.instanceId) {
|
|
956
|
+
info = found;
|
|
957
|
+
break;
|
|
380
958
|
}
|
|
381
|
-
|
|
959
|
+
last = "no live instance";
|
|
382
960
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
961
|
+
catch (error) {
|
|
962
|
+
if (error instanceof TimeoutError && deadline.expired())
|
|
963
|
+
break;
|
|
964
|
+
// Not open yet is "not yet"; an unknown page name is an answer.
|
|
965
|
+
if (!matchesErrorCode(error, "E_PAGE_NOT_ACTIVE") && !isTransientTransportError(error))
|
|
966
|
+
throw error;
|
|
967
|
+
last = error instanceof Error ? error.message : String(error);
|
|
968
|
+
}
|
|
969
|
+
if (deadline.expired()) {
|
|
970
|
+
throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms waiting for ${wanted} to open: ${last}.` +
|
|
971
|
+
(deadline.clampNote() ? `\n${deadline.clampNote()}` : ""));
|
|
972
|
+
}
|
|
973
|
+
await sleep(Math.min(DEFAULT_POLL_INTERVAL_MS, Math.max(1, deadline.remaining())));
|
|
974
|
+
}
|
|
975
|
+
if (!info) {
|
|
976
|
+
throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms waiting for ${wanted} to open: ${last}.` +
|
|
977
|
+
(deadline.clampNote() ? `\n${deadline.clampNote()}` : ""));
|
|
978
|
+
}
|
|
979
|
+
const instanceId = info.instanceId;
|
|
980
|
+
if (selector?.name !== undefined) {
|
|
981
|
+
const stack = await deadline.call("t.app.page stack read", () => app.nav.stack(), () => `checking whether ${wanted} is ambiguous`);
|
|
982
|
+
if (deadline.expired()) {
|
|
983
|
+
throw new TimeoutError(`Timed out after ${deadline.elapsed()}ms checking ${wanted}.`);
|
|
984
|
+
}
|
|
985
|
+
const matches = stack.filter(page => page.path === info.path && page.instanceId);
|
|
986
|
+
if (matches.length > 1) {
|
|
987
|
+
throw new Error(`t.app.page: ${wanted} has ${matches.length} live instances ` +
|
|
988
|
+
`(${matches.map(page => `#${page.instanceId}`).join(", ")}); select one by instanceId`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
this.boundPages.add(instanceId);
|
|
992
|
+
const view = this.wrapView(() => app.page, instanceId);
|
|
993
|
+
const invoke = (name, args, timeout) => {
|
|
994
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
995
|
+
return Promise.reject(new TypeError("page.invoke needs a non-empty action name"));
|
|
996
|
+
}
|
|
997
|
+
if (args.length > 1) {
|
|
998
|
+
return Promise.reject(new TypeError(`page.actions.${name} takes at most one JSON payload`));
|
|
999
|
+
}
|
|
1000
|
+
if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
1001
|
+
return Promise.reject(new TypeError("page.invoke({ timeout }) takes a positive number of ms"));
|
|
1002
|
+
}
|
|
1003
|
+
checkJsonArgs(args, `page.actions.${name}`);
|
|
1004
|
+
// A timeout stops waiting, but cannot undo a side effect already
|
|
1005
|
+
// performed by the action. Never retry dispatch automatically.
|
|
1006
|
+
return this.act("page.action", `${name} #${instanceId}`, () => {
|
|
1007
|
+
const budget = Math.floor(Math.min(timeout ?? Number.POSITIVE_INFINITY, this.budgetRoom()));
|
|
1008
|
+
if (budget < 1) {
|
|
1009
|
+
throw new TimeoutError(`page.action ${name} #${instanceId} was not dispatched: its action budget expired`);
|
|
1010
|
+
}
|
|
1011
|
+
return app.page.action({
|
|
1012
|
+
page: instanceId, name, ...(args.length > 0 ? { payload: args[0] } : {}),
|
|
1013
|
+
timeoutMs: budget,
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
};
|
|
1017
|
+
const actions = new Proxy(Object.create(null), {
|
|
1018
|
+
get: (_, name) => {
|
|
1019
|
+
if (typeof name !== "string" || name === "then")
|
|
1020
|
+
return undefined;
|
|
1021
|
+
return (...args) => invoke(name, args);
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
return {
|
|
1025
|
+
instanceId,
|
|
1026
|
+
name: info.name ?? info.path,
|
|
1027
|
+
view,
|
|
1028
|
+
actions,
|
|
1029
|
+
invoke: (name, options) => {
|
|
1030
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
1031
|
+
return Promise.reject(new TypeError("page.invoke(name, { payload?, timeout? }) needs an options object"));
|
|
1032
|
+
}
|
|
1033
|
+
return invoke(name, Object.prototype.hasOwnProperty.call(options, "payload") ? [options.payload] : [], options.timeout);
|
|
1034
|
+
},
|
|
1035
|
+
data: () => this.act("page.data", `#${instanceId}`, () => remote("page.data", "logic", () => this.evalLogic(app, {
|
|
1036
|
+
script: logicScript(readPageData, [instanceId], "page.data", "snapshot"),
|
|
1037
|
+
}))),
|
|
1038
|
+
};
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
/** The driver's options for a fixture nav action. */
|
|
1042
|
+
navOptions(options, api) {
|
|
1043
|
+
if (!options || typeof options !== "object")
|
|
1044
|
+
throw new TypeError(`${api} takes an options object`);
|
|
1045
|
+
if ("timeoutMs" in options)
|
|
1046
|
+
throw new TypeError(`${api} takes { timeout } in ms`);
|
|
1047
|
+
const { timeout, waitUntil = "ready", ...rest } = options;
|
|
1048
|
+
if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
|
|
1049
|
+
throw new TypeError(`${api}({ timeout }) takes a positive number of ms`);
|
|
1050
|
+
}
|
|
1051
|
+
if (waitUntil !== "ready")
|
|
1052
|
+
return { ...rest, waitUntil };
|
|
1053
|
+
const room = Math.max(1, Math.floor(this.budgetRoom()));
|
|
1054
|
+
return { ...rest, waitUntil, timeoutMs: Math.min(timeout ?? NAV_READY_TIMEOUT_MS, room) };
|
|
1055
|
+
}
|
|
1056
|
+
wrapLogic(driver) {
|
|
1057
|
+
return {
|
|
1058
|
+
eval: (...input) => {
|
|
1059
|
+
const { options, fn, args } = evalInput(input);
|
|
1060
|
+
if (typeof fn !== "function") {
|
|
1061
|
+
throw new TypeError("t.app.logic.eval(fn, ...args) takes a function; a script string is for the raw driver (rawAutomation().lxapp().eval({ script }) from @lingxia/test/runner)");
|
|
1062
|
+
}
|
|
1063
|
+
const script = logicScript(fn, args, "t.app.logic.eval");
|
|
1064
|
+
const timeoutMs = this.evalTimeout(options, "t.app.logic.eval");
|
|
1065
|
+
const detail = `${summarise(functionDetail(fn))}${args.length ? ` args=${summarise(args)}` : ""}`;
|
|
1066
|
+
return this.act("logic.eval", detail, () => remote("t.app.logic.eval", "logic", () => this.evalLogic(driver(), { script, timeoutMs })));
|
|
1067
|
+
},
|
|
386
1068
|
};
|
|
387
1069
|
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Logic eval that asks the runtime which `lx.*` the script reached and
|
|
1072
|
+
* hands the caller only the value — the observation is the report's
|
|
1073
|
+
* business, not the spec author's, and specs must not have to opt in for
|
|
1074
|
+
* their coverage to be measured.
|
|
1075
|
+
*/
|
|
1076
|
+
async evalLogic(driver, options) {
|
|
1077
|
+
const result = (await driver.eval({
|
|
1078
|
+
...this.withEvalBudget(options, "Logic eval"),
|
|
1079
|
+
captureCalls: true,
|
|
1080
|
+
}));
|
|
1081
|
+
// The marker, not the shape, identifies the envelope: a script that
|
|
1082
|
+
// returns undefined loses its `value` key on the wire, and sniffing
|
|
1083
|
+
// for that key handed the envelope itself back as the result.
|
|
1084
|
+
if (result && typeof result === "object" && result.__lxEval === 1) {
|
|
1085
|
+
const { calls, value } = result;
|
|
1086
|
+
if (Array.isArray(calls)) {
|
|
1087
|
+
for (const call of calls) {
|
|
1088
|
+
if (typeof call === "string")
|
|
1089
|
+
this.observed.add(call);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return value;
|
|
1093
|
+
}
|
|
1094
|
+
throw new Error("t.app.logic.eval: the host answered without the call-capture envelope; " +
|
|
1095
|
+
"it is older than this @lingxia/test — run `lingxia upgrade` and restart the session (`lingxia dev`)");
|
|
1096
|
+
}
|
|
388
1097
|
/**
|
|
389
1098
|
* The driver's own eval default is a flat few seconds, so a call that runs
|
|
390
1099
|
* long under load fails a spec that still had most of its budget left. An
|
|
391
|
-
* eval gets
|
|
392
|
-
* all of it: a call allowed to run the full budget leaves the spec no room
|
|
393
|
-
* to retry, so one stalled call takes the whole spec down with it.
|
|
1100
|
+
* eval gets `MAX_EVAL_BUDGET_MS` instead, clamped to what the spec has left.
|
|
394
1101
|
*/
|
|
395
|
-
withEvalBudget(options) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1102
|
+
withEvalBudget(options, name) {
|
|
1103
|
+
const budget = Math.floor(Math.min(options.timeoutMs ?? MAX_EVAL_BUDGET_MS, this.budgetRoom()));
|
|
1104
|
+
if (budget < 1)
|
|
1105
|
+
throw new TimeoutError(`${name} was not dispatched: its action budget expired`);
|
|
1106
|
+
return { ...options, timeoutMs: budget };
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* An eval's own `timeout`, clamped to the spec's remaining budget; without
|
|
1110
|
+
* one, `withEvalBudget` picks the default.
|
|
1111
|
+
*/
|
|
1112
|
+
evalTimeout(options, api) {
|
|
1113
|
+
const timeout = options?.timeout;
|
|
1114
|
+
if (timeout === undefined)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
1117
|
+
throw new TypeError(`${api}({ timeout }, fn, ...args) takes a positive number of ms`);
|
|
1118
|
+
}
|
|
1119
|
+
return Math.min(timeout, this.budgetRoom());
|
|
1120
|
+
}
|
|
1121
|
+
viewEval(page, input, api, bound) {
|
|
1122
|
+
const { options, fn, args } = evalInput(input);
|
|
1123
|
+
if (typeof fn !== "function") {
|
|
1124
|
+
throw new TypeError(`${api}(fn, ...args) takes a function; a script string is for the raw driver (rawAutomation().lxapp().page.eval({ script }) from @lingxia/test/runner)`);
|
|
1125
|
+
}
|
|
1126
|
+
if (options && "page" in options) {
|
|
1127
|
+
throw new TypeError(`${api}: page is not an eval option; bind the page with t.app.page({ name }) and use its view`);
|
|
399
1128
|
}
|
|
400
|
-
|
|
1129
|
+
const script = pageScript(fn, args, api);
|
|
1130
|
+
const timeoutMs = this.evalTimeout(options, api);
|
|
1131
|
+
const detail = `${summarise(functionDetail(fn))}${args.length ? ` args=${summarise(args)}` : ""}`;
|
|
1132
|
+
return this.act("view.eval", bound ? `#${bound} ${detail}` : detail, () => remote(api, "page", () => page().eval(this.withEvalBudget({
|
|
1133
|
+
script,
|
|
1134
|
+
...(bound ? { page: bound } : {}),
|
|
1135
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
1136
|
+
}, "View eval"))));
|
|
401
1137
|
}
|
|
402
|
-
|
|
1138
|
+
/** `bound` is the immutable instance id captured by app.page(). */
|
|
1139
|
+
wrapView(page, bound) {
|
|
403
1140
|
const location = () => {
|
|
404
1141
|
const frame = callerLocation();
|
|
405
1142
|
return { source: frame.file, line: frame.line, column: frame.column };
|
|
406
1143
|
};
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1144
|
+
// Resolved on each call, like the rest of a fixture app.
|
|
1145
|
+
const lazyPage = {
|
|
1146
|
+
query: (options) => page().query(options),
|
|
1147
|
+
click: (options) => page().click(options),
|
|
1148
|
+
fill: (options) => page().fill(options),
|
|
1149
|
+
press: (options) => page().press(options),
|
|
1150
|
+
type: (options) => page().type(options),
|
|
1151
|
+
eval: (options) => page().eval(options),
|
|
414
1152
|
};
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1153
|
+
const input = lazyDriver(() => ({ owner: page(), value: page() }), this, "page.");
|
|
1154
|
+
const target = bound === undefined ? {} : { page: bound };
|
|
1155
|
+
const noOptions = (api, options) => {
|
|
1156
|
+
if (options !== undefined) {
|
|
1157
|
+
throw new TypeError(`${api}() takes no options: bind another page with t.app.page({ name }) and use its view`);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
testId: (id, options) => {
|
|
1162
|
+
noOptions("view.testId", options);
|
|
1163
|
+
return this.locator(lazyPage, testIdSelector(id), location(), target);
|
|
421
1164
|
},
|
|
422
|
-
|
|
1165
|
+
css: (selector, options) => {
|
|
1166
|
+
noOptions("view.css", options);
|
|
1167
|
+
return this.locator(lazyPage, selector, location(), target);
|
|
1168
|
+
},
|
|
1169
|
+
eval: ((...args) => this.viewEval(page, args, bound ? "page.view.eval" : "t.app.view.eval", bound)),
|
|
1170
|
+
screenshot: (options) => {
|
|
1171
|
+
noOptions("view.screenshot", options);
|
|
1172
|
+
return this.act("page.screenshot", bound ? `#${bound}` : "", () => this.readRetrying(() => page().screenshot(bound ? target : undefined)));
|
|
1173
|
+
},
|
|
1174
|
+
scroll: (options) => {
|
|
1175
|
+
if (options && "page" in options) {
|
|
1176
|
+
throw new TypeError("view.scroll() takes no page option: bind another page with t.app.page({ name }) and use its view");
|
|
1177
|
+
}
|
|
1178
|
+
return input.scroll({ ...options, ...target });
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
423
1181
|
}
|
|
424
|
-
locator(page, selector, location) {
|
|
425
|
-
|
|
1182
|
+
locator(page, selector, location, options) {
|
|
1183
|
+
this.usedPage = true;
|
|
1184
|
+
return new PageLocator(page, (fn) => this.guard(fn), (verb, detail, op) => this.act(verb, detail, op), selector, location, options, () => this.budgetRoom());
|
|
426
1185
|
}
|
|
427
|
-
locatorMatchers(locator, inverted) {
|
|
1186
|
+
locatorMatchers(locator, inverted, message) {
|
|
1187
|
+
const withMessage = (options) => message === undefined || options?.message !== undefined ? options : { ...options, message };
|
|
428
1188
|
const self = {
|
|
429
|
-
toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, options, inverted ? "not visible" : "visible"),
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1189
|
+
toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, withMessage(options), inverted ? "not visible" : "visible"),
|
|
1190
|
+
toBeInViewport: (options) => this.retryLocator(locator, "toBeInViewport", inverted, withMessage(options), inverted ? "not in viewport" : "in viewport"),
|
|
1191
|
+
toBeHidden: (options) => this.retryLocator(locator, "toBeHidden", inverted, withMessage(options), true),
|
|
1192
|
+
toBeAttached: (options) => this.retryLocator(locator, "toBeAttached", inverted, withMessage(options), true),
|
|
1193
|
+
toBeEnabled: (options) => this.retryLocator(locator, "toBeEnabled", inverted, withMessage(options), true),
|
|
1194
|
+
toBeDisabled: (options) => this.retryLocator(locator, "toBeDisabled", inverted, withMessage(options), true),
|
|
1195
|
+
toBeEditable: (options) => this.retryLocator(locator, "toBeEditable", inverted, withMessage(options), true),
|
|
1196
|
+
toHaveText: (expected, options) => this.retryLocator(locator, "toHaveText", inverted, withMessage(options), expected),
|
|
1197
|
+
toContainText: (expected, options) => this.retryLocator(locator, "toContainText", inverted, withMessage(options), expected),
|
|
1198
|
+
toHaveAttribute: (name, value, options) => {
|
|
1199
|
+
if (typeof name !== "string" || !name)
|
|
1200
|
+
throw new TypeError("toHaveAttribute needs an attribute name");
|
|
1201
|
+
return this.retryLocator(locator, "toHaveAttribute", inverted, withMessage(options), new AttributeExpectation(name, value));
|
|
1202
|
+
},
|
|
1203
|
+
toHaveCount: (expected, options) => this.retryLocator(locator, "toHaveCount", inverted, withMessage(options), expected),
|
|
1204
|
+
toHaveValue: (expected, options) => this.retryLocator(locator, "toHaveValue", inverted, withMessage(options), expected),
|
|
433
1205
|
};
|
|
434
1206
|
Object.defineProperty(self, "not", {
|
|
435
|
-
get: () => this.locatorMatchers(locator, !inverted),
|
|
1207
|
+
get: () => this.locatorMatchers(locator, !inverted, message),
|
|
1208
|
+
configurable: true,
|
|
436
1209
|
});
|
|
437
1210
|
return self;
|
|
438
1211
|
}
|
|
439
|
-
pollMatchers(read, options, inverted) {
|
|
440
|
-
const run = (matcher, expected) => this.retryPoll(read, matcher, inverted, options, expected);
|
|
1212
|
+
pollMatchers(read, options, inverted, api) {
|
|
1213
|
+
const run = (matcher, expected) => this.retryPoll(read, matcher, inverted, options, expected, api);
|
|
441
1214
|
const fixture = this;
|
|
442
1215
|
const self = {
|
|
443
1216
|
toBe: (expected) => run("toBe", expected),
|
|
444
1217
|
toEqual: (expected) => run("toEqual", expected),
|
|
445
1218
|
toContain: (expected) => run("toContain", expected),
|
|
1219
|
+
toContainEqual: (expected) => run("toContainEqual", expected),
|
|
446
1220
|
toMatch: (expected) => run("toMatch", expected),
|
|
447
1221
|
toBeTruthy: () => run("toBeTruthy"),
|
|
448
1222
|
toBeFalsy: () => run("toBeFalsy"),
|
|
449
1223
|
toBeDefined: () => run("toBeDefined"),
|
|
450
1224
|
toBeUndefined: () => run("toBeUndefined"),
|
|
451
1225
|
toBeInstanceOf: (expected) => run("toBeInstanceOf", expected),
|
|
1226
|
+
toHaveLength: (expected) => run("toHaveLength", expected),
|
|
452
1227
|
toBeGreaterThan: (expected) => run("toBeGreaterThan", expected),
|
|
453
1228
|
toBeGreaterThanOrEqual: (expected) => run("toBeGreaterThanOrEqual", expected),
|
|
454
1229
|
toBeLessThan: (expected) => run("toBeLessThan", expected),
|
|
455
1230
|
toBeLessThanOrEqual: (expected) => run("toBeLessThanOrEqual", expected),
|
|
456
1231
|
};
|
|
457
1232
|
Object.defineProperty(self, "not", {
|
|
458
|
-
get: () => fixture.pollMatchers(read, options, !inverted),
|
|
1233
|
+
get: () => fixture.pollMatchers(read, options, !inverted, api),
|
|
1234
|
+
configurable: true,
|
|
459
1235
|
});
|
|
460
1236
|
return self;
|
|
461
1237
|
}
|
|
462
|
-
|
|
463
|
-
|
|
1238
|
+
retryLocator(locator, matcher, inverted, options, expected) {
|
|
1239
|
+
const target = locator instanceof PageLocator ? locator.describe() : locator.selector;
|
|
1240
|
+
const assertion = `${inverted ? "not." : ""}${matcher}`;
|
|
1241
|
+
return this.track("expect", `${target} ${assertion}`, () => this.guard(async () => {
|
|
464
1242
|
const frame = callerLocation();
|
|
465
1243
|
const location = { source: frame.file, line: frame.line, column: frame.column };
|
|
466
|
-
const
|
|
1244
|
+
const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
|
|
467
1245
|
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
468
|
-
const
|
|
1246
|
+
const context = () => this.deadlineContext(inverted ? `not.${matcher}` : matcher, location);
|
|
469
1247
|
let lastResolved;
|
|
470
1248
|
let lastError;
|
|
471
1249
|
pushAssertionSilence();
|
|
472
1250
|
this.silenceActions();
|
|
473
1251
|
try {
|
|
474
|
-
while (
|
|
1252
|
+
while (!deadline.expired()) {
|
|
475
1253
|
try {
|
|
476
|
-
lastResolved = await resolveLocator(locator);
|
|
1254
|
+
lastResolved = await resolveLocator(locator, deadline, context, expected instanceof AttributeExpectation ? [expected.name] : []);
|
|
477
1255
|
matchLocator(locator, lastResolved, matcher, expected, inverted);
|
|
478
1256
|
this.noteAssertion({
|
|
479
1257
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
480
1258
|
expected: formatValue(expected),
|
|
481
|
-
actual: formatValue(locatorActual(matcher, lastResolved)),
|
|
1259
|
+
actual: formatValue(locatorActual(matcher, lastResolved, expected)),
|
|
482
1260
|
passed: true,
|
|
1261
|
+
target,
|
|
1262
|
+
message: options?.message,
|
|
1263
|
+
location: displayLocation(location.source, location.line, location.column),
|
|
1264
|
+
duration_ms: deadline.elapsed(),
|
|
483
1265
|
});
|
|
484
1266
|
return;
|
|
485
1267
|
}
|
|
486
1268
|
catch (error) {
|
|
487
|
-
if (error instanceof TimeoutError || this.aborted)
|
|
1269
|
+
if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
|
|
488
1270
|
throw error;
|
|
489
1271
|
lastError = error;
|
|
490
1272
|
}
|
|
491
|
-
if (
|
|
1273
|
+
if (deadline.expired())
|
|
492
1274
|
break;
|
|
493
|
-
await sleep(interval);
|
|
1275
|
+
await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
|
|
494
1276
|
if (this.aborted && this.abortError)
|
|
495
1277
|
throw this.abortError;
|
|
496
1278
|
}
|
|
@@ -499,50 +1281,61 @@ export class LiveFixture {
|
|
|
499
1281
|
popAssertionSilence();
|
|
500
1282
|
this.resumeActions();
|
|
501
1283
|
}
|
|
502
|
-
const duration =
|
|
1284
|
+
const duration = deadline.elapsed();
|
|
1285
|
+
const miss = lastResolved && locator instanceof PageLocator ? locator.missText(lastResolved) : undefined;
|
|
503
1286
|
throw this.retryFailure({
|
|
1287
|
+
clampNote: deadline.clampNote(),
|
|
504
1288
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
505
1289
|
expected,
|
|
506
|
-
actual: locatorActual(matcher, lastResolved),
|
|
1290
|
+
actual: locatorActual(matcher, lastResolved, expected),
|
|
507
1291
|
duration,
|
|
508
1292
|
location,
|
|
509
1293
|
lastError,
|
|
510
|
-
extra:
|
|
1294
|
+
extra: miss,
|
|
1295
|
+
locator: locator instanceof PageLocator ? locator.boundPage : null,
|
|
1296
|
+
target,
|
|
1297
|
+
message: options?.message,
|
|
511
1298
|
});
|
|
512
|
-
});
|
|
1299
|
+
}), `expect(${target}).${assertion}`);
|
|
513
1300
|
}
|
|
514
|
-
|
|
515
|
-
|
|
1301
|
+
retryPoll(read, matcher, inverted, options, expected, api) {
|
|
1302
|
+
const source = truncate(read.name || functionDetail(read), 60);
|
|
1303
|
+
const assertion = `${inverted ? "not." : ""}${matcher}`;
|
|
1304
|
+
return this.track(api, `${source} ${assertion}`, () => this.guard(async () => {
|
|
516
1305
|
const frame = callerLocation();
|
|
517
1306
|
const location = { source: frame.file, line: frame.line, column: frame.column };
|
|
518
|
-
const
|
|
1307
|
+
const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
|
|
519
1308
|
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
520
|
-
const
|
|
1309
|
+
const context = () => this.deadlineContext(`${api} ${inverted ? "not." : ""}${matcher}`, location);
|
|
521
1310
|
let lastActual;
|
|
522
1311
|
let lastError;
|
|
523
1312
|
pushAssertionSilence();
|
|
524
1313
|
this.silenceActions();
|
|
525
1314
|
try {
|
|
526
|
-
while (
|
|
1315
|
+
while (!deadline.expired()) {
|
|
527
1316
|
try {
|
|
528
|
-
lastActual = await read
|
|
1317
|
+
lastActual = await deadline.call(`${api} read`, read, context);
|
|
529
1318
|
applyMatcher(matcher, lastActual, expected, inverted);
|
|
530
1319
|
this.noteAssertion({
|
|
531
1320
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
532
1321
|
expected: formatValue(expected),
|
|
533
1322
|
actual: formatValue(lastActual),
|
|
534
1323
|
passed: true,
|
|
1324
|
+
target: source,
|
|
1325
|
+
message: options?.message,
|
|
1326
|
+
location: displayLocation(location.source, location.line, location.column),
|
|
1327
|
+
duration_ms: deadline.elapsed(),
|
|
535
1328
|
});
|
|
536
1329
|
return;
|
|
537
1330
|
}
|
|
538
1331
|
catch (error) {
|
|
539
|
-
if (error instanceof TimeoutError || this.aborted)
|
|
1332
|
+
if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
|
|
540
1333
|
throw error;
|
|
541
1334
|
lastError = error;
|
|
542
1335
|
}
|
|
543
|
-
if (
|
|
1336
|
+
if (deadline.expired())
|
|
544
1337
|
break;
|
|
545
|
-
await sleep(interval);
|
|
1338
|
+
await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
|
|
546
1339
|
if (this.aborted && this.abortError)
|
|
547
1340
|
throw this.abortError;
|
|
548
1341
|
}
|
|
@@ -552,14 +1345,24 @@ export class LiveFixture {
|
|
|
552
1345
|
this.resumeActions();
|
|
553
1346
|
}
|
|
554
1347
|
throw this.retryFailure({
|
|
1348
|
+
clampNote: deadline.clampNote(),
|
|
555
1349
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
556
1350
|
expected,
|
|
557
1351
|
actual: lastActual,
|
|
558
|
-
duration:
|
|
1352
|
+
duration: deadline.elapsed(),
|
|
559
1353
|
location,
|
|
560
1354
|
lastError,
|
|
1355
|
+
target: source,
|
|
1356
|
+
message: options?.message,
|
|
561
1357
|
});
|
|
562
|
-
});
|
|
1358
|
+
}), `${api}(${source}).${assertion}`);
|
|
1359
|
+
}
|
|
1360
|
+
deadlineContext(matcher, location) {
|
|
1361
|
+
return [
|
|
1362
|
+
`while retrying ${matcher}`,
|
|
1363
|
+
`at ${displayLocation(location.source, location.line, location.column)}`,
|
|
1364
|
+
this.stepPathLine(),
|
|
1365
|
+
].filter(Boolean).join("\n");
|
|
563
1366
|
}
|
|
564
1367
|
retryFailure(input) {
|
|
565
1368
|
const where = displayLocation(input.location.source, input.location.line, input.location.column);
|
|
@@ -569,11 +1372,13 @@ export class LiveFixture {
|
|
|
569
1372
|
? input.lastError.message
|
|
570
1373
|
: undefined;
|
|
571
1374
|
const lines = [
|
|
1375
|
+
input.message,
|
|
572
1376
|
`Timed out after ${input.duration}ms retrying ${input.matcher}.`,
|
|
573
1377
|
input.extra,
|
|
574
1378
|
`Expected: ${formatValue(input.expected)}`,
|
|
575
1379
|
`Received: ${formatValue(input.actual)}`,
|
|
576
1380
|
`Retried for ${input.duration}ms`,
|
|
1381
|
+
input.clampNote,
|
|
577
1382
|
`at ${where}`,
|
|
578
1383
|
this.stepPathLine(),
|
|
579
1384
|
last && last !== `Expected: ${formatValue(input.expected)}` ? last : undefined,
|
|
@@ -583,18 +1388,129 @@ export class LiveFixture {
|
|
|
583
1388
|
expected: formatValue(input.expected),
|
|
584
1389
|
actual: formatValue(input.actual),
|
|
585
1390
|
passed: false,
|
|
1391
|
+
difference: ["toBe", "toEqual"].includes(input.matcher)
|
|
1392
|
+
? firstDifference(input.expected, input.actual) : undefined,
|
|
1393
|
+
target: input.target,
|
|
1394
|
+
message: input.message,
|
|
1395
|
+
location: where,
|
|
1396
|
+
duration_ms: input.duration,
|
|
586
1397
|
});
|
|
587
|
-
|
|
1398
|
+
const error = new AssertionError(input.matcher, input.actual, input.expected, lines.join("\n"), "E_TIMEOUT");
|
|
1399
|
+
if (input.locator !== undefined)
|
|
1400
|
+
this.locatorFailures.set(error, input.locator);
|
|
1401
|
+
return error;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/** An inline scenario (or its selected variant) with `function` rules. */
|
|
1405
|
+
function hasFunctionRules(definition, variant) {
|
|
1406
|
+
if (!definition || typeof definition !== "object")
|
|
1407
|
+
return false;
|
|
1408
|
+
const record = definition;
|
|
1409
|
+
const rules = [
|
|
1410
|
+
...(Array.isArray(record.rules) ? record.rules : []),
|
|
1411
|
+
...(variant !== undefined && Array.isArray(record.variants?.[variant]?.rules) ? record.variants[variant].rules : []),
|
|
1412
|
+
];
|
|
1413
|
+
return rules.some((rule) => !!rule && typeof rule === "object" && "function" in rule);
|
|
1414
|
+
}
|
|
1415
|
+
/** Extra attempts of an idempotent read the transport dropped, and the backoff step. */
|
|
1416
|
+
const TRANSPORT_RETRIES = 2;
|
|
1417
|
+
const TRANSPORT_RETRY_DELAY_MS = 100;
|
|
1418
|
+
/**
|
|
1419
|
+
* Host tiers a host may lack. The fixture resolves them per call, so reading
|
|
1420
|
+
* `t.automation.desktop` never throws.
|
|
1421
|
+
*/
|
|
1422
|
+
const HOST_TIERS = ["browser", "desktop", "terminal"];
|
|
1423
|
+
/** Driver reads with no side effect, retried when the transport drops them. */
|
|
1424
|
+
const IDEMPOTENT_READS = new Set(["nav.current", "nav.info", "nav.stack", "page.query", "page.screenshot", "lxapps.list", "lxapps.current"]);
|
|
1425
|
+
function checkpointId(checkpoint, api) {
|
|
1426
|
+
const id = typeof checkpoint === "string" ? checkpoint : checkpoint?.id;
|
|
1427
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
1428
|
+
throw new TypeError(`${api} needs the checkpoint t.app.profile.checkpoint() resolved (or its id)`);
|
|
588
1429
|
}
|
|
1430
|
+
return id;
|
|
589
1431
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1432
|
+
/**
|
|
1433
|
+
* `t.waitFor` retries a read that says "not yet" by throwing, but not one
|
|
1434
|
+
* that is invalid locally or violates the eval JSON boundary. Remote DOM
|
|
1435
|
+
* TypeErrors can resolve as rendering catches up.
|
|
1436
|
+
*/
|
|
1437
|
+
function isRetryableReadError(error) {
|
|
1438
|
+
if (error instanceof AssertionError || isRetryableRemoteError(error))
|
|
1439
|
+
return true;
|
|
1440
|
+
const name = error instanceof Error ? error.name : undefined;
|
|
1441
|
+
return name !== "TypeError" && name !== "ReferenceError" && name !== "SyntaxError";
|
|
594
1442
|
}
|
|
595
|
-
function
|
|
1443
|
+
function errorLine(error) {
|
|
1444
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : formatValue(error);
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* `eval(fn, ...args)` or `eval(options, fn, ...args)`. An object with a
|
|
1448
|
+
* `script` key is a script string's options, not eval options: it stays the
|
|
1449
|
+
* "function" so the caller refuses it as such.
|
|
1450
|
+
*/
|
|
1451
|
+
function evalInput(input) {
|
|
1452
|
+
const [first, ...rest] = input;
|
|
1453
|
+
if (first !== null && typeof first === "object" && !("script" in first)) {
|
|
1454
|
+
const [fn, ...args] = rest;
|
|
1455
|
+
return { options: first, fn, args };
|
|
1456
|
+
}
|
|
1457
|
+
return { options: undefined, fn: first, args: rest };
|
|
1458
|
+
}
|
|
1459
|
+
async function remote(api, target, op) {
|
|
1460
|
+
try {
|
|
1461
|
+
return await op();
|
|
1462
|
+
}
|
|
1463
|
+
catch (error) {
|
|
1464
|
+
throw explainRemoteError(error, api, target);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
// Sent to app Logic as source text: it must stay self-contained.
|
|
1468
|
+
function readPageData(scope, instanceId) {
|
|
1469
|
+
const page = scope.getPage(instanceId);
|
|
1470
|
+
if (!page)
|
|
1471
|
+
throw new Error(`page.data: page instance #${instanceId} is gone`);
|
|
1472
|
+
return page.data;
|
|
1473
|
+
}
|
|
1474
|
+
function resolveLocator(locator, deadline, context, attributes = []) {
|
|
1475
|
+
if (locator instanceof PageLocator) {
|
|
1476
|
+
return deadline.call("locator read", () => locator.resolve(undefined, "resolve", attributes), context);
|
|
1477
|
+
}
|
|
1478
|
+
throw new Error("expect(locator) takes a locator from view.testId() or view.css()");
|
|
1479
|
+
}
|
|
1480
|
+
/** `toHaveAttribute`'s expectation; formats as the report shows it. */
|
|
1481
|
+
class AttributeExpectation {
|
|
1482
|
+
constructor(name, value) {
|
|
1483
|
+
this.name = name;
|
|
1484
|
+
this.value = value;
|
|
1485
|
+
}
|
|
1486
|
+
toString() {
|
|
1487
|
+
return this.value === undefined ? `attribute ${this.name}` : `${this.name}=${formatValue(this.value)}`;
|
|
1488
|
+
}
|
|
1489
|
+
toJSON() {
|
|
1490
|
+
return this.toString();
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
function locatorActual(matcher, resolved, expected) {
|
|
596
1494
|
if (!resolved)
|
|
597
1495
|
return undefined;
|
|
1496
|
+
if (matcher === "toBeInViewport") {
|
|
1497
|
+
return resolved.kind === "unique" ? (resolved.inViewport ? "in viewport" : "outside viewport") : resolved.kind;
|
|
1498
|
+
}
|
|
1499
|
+
if (matcher === "toContainText")
|
|
1500
|
+
return resolved.text;
|
|
1501
|
+
if (matcher === "toHaveAttribute") {
|
|
1502
|
+
return expected instanceof AttributeExpectation ? resolved.attributes?.[expected.name] ?? null : undefined;
|
|
1503
|
+
}
|
|
1504
|
+
if (matcher === "toBeHidden")
|
|
1505
|
+
return !resolved.visible;
|
|
1506
|
+
if (matcher === "toBeAttached")
|
|
1507
|
+
return resolved.attached;
|
|
1508
|
+
if (matcher === "toBeEnabled")
|
|
1509
|
+
return resolved.enabled;
|
|
1510
|
+
if (matcher === "toBeDisabled")
|
|
1511
|
+
return resolved.enabled === false;
|
|
1512
|
+
if (matcher === "toBeEditable")
|
|
1513
|
+
return resolved.editable;
|
|
598
1514
|
if (matcher === "toHaveCount")
|
|
599
1515
|
return resolved.count;
|
|
600
1516
|
if (matcher === "toHaveText")
|
|
@@ -606,6 +1522,13 @@ function locatorActual(matcher, resolved) {
|
|
|
606
1522
|
return resolved.kind;
|
|
607
1523
|
}
|
|
608
1524
|
function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
1525
|
+
if (["toBeHidden", "toBeAttached", "toBeEnabled", "toBeDisabled", "toBeEditable"].includes(matcher)) {
|
|
1526
|
+
if (["toBeEnabled", "toBeDisabled", "toBeEditable"].includes(matcher) && resolved.count !== 1) {
|
|
1527
|
+
throw new Error(`Expected one attached element, received ${resolved.count}`);
|
|
1528
|
+
}
|
|
1529
|
+
applyMatcher("toBe", locatorActual(matcher, resolved), true, inverted);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
609
1532
|
if (matcher === "toBeVisible") {
|
|
610
1533
|
const pass = resolved.visible && resolved.kind === "unique";
|
|
611
1534
|
if (pass === inverted) {
|
|
@@ -614,6 +1537,35 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
614
1537
|
}
|
|
615
1538
|
return;
|
|
616
1539
|
}
|
|
1540
|
+
if (matcher === "toBeInViewport") {
|
|
1541
|
+
const pass = resolved.kind === "unique" && resolved.inViewport;
|
|
1542
|
+
if (pass === inverted) {
|
|
1543
|
+
const detail = locator instanceof PageLocator ? locator.missText(resolved) : `resolved to ${resolved.kind}`;
|
|
1544
|
+
const actual = String(locatorActual(matcher, resolved));
|
|
1545
|
+
throw new AssertionError(inverted ? "not.toBeInViewport" : "toBeInViewport", actual, inverted ? "not in viewport" : "in viewport", `${detail}\nExpected: ${inverted ? "not " : ""}in viewport\nReceived: ${formatValue(actual)}`);
|
|
1546
|
+
}
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (matcher === "toContainText") {
|
|
1550
|
+
if (expected instanceof RegExp)
|
|
1551
|
+
applyMatcher("toMatch", resolved.text, expected, inverted);
|
|
1552
|
+
else
|
|
1553
|
+
applyMatcher("toContain", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (matcher === "toHaveAttribute" && expected instanceof AttributeExpectation) {
|
|
1557
|
+
if (resolved.count !== 1)
|
|
1558
|
+
throw new Error(`Expected one attached element, received ${resolved.count}`);
|
|
1559
|
+
const actual = resolved.attributes?.[expected.name] ?? null;
|
|
1560
|
+
const pass = actual !== null && (expected.value === undefined ? true
|
|
1561
|
+
: expected.value instanceof RegExp ? (expected.value.lastIndex = 0, expected.value.test(actual))
|
|
1562
|
+
: actual === expected.value);
|
|
1563
|
+
if (pass === inverted) {
|
|
1564
|
+
const name = inverted ? "not.toHaveAttribute" : "toHaveAttribute";
|
|
1565
|
+
throw new AssertionError(name, formatValue(actual), `${inverted ? "not " : ""}${expected}`, `Expected: ${inverted ? "not " : ""}${expected}\nReceived: ${actual === null ? `no ${expected.name} attribute` : `${expected.name}=${formatValue(actual)}`}`);
|
|
1566
|
+
}
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
617
1569
|
if (matcher === "toHaveCount") {
|
|
618
1570
|
applyMatcher("toBe", resolved.count, expected, inverted);
|
|
619
1571
|
return;
|
|
@@ -622,7 +1574,7 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
622
1574
|
if (expected instanceof RegExp)
|
|
623
1575
|
applyMatcher("toMatch", resolved.text, expected, inverted);
|
|
624
1576
|
else
|
|
625
|
-
applyMatcher("toBe", resolved.text, expected, inverted);
|
|
1577
|
+
applyMatcher("toBe", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
|
|
626
1578
|
return;
|
|
627
1579
|
}
|
|
628
1580
|
if (matcher === "toHaveValue") {
|
|
@@ -632,22 +1584,40 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
632
1584
|
applyMatcher("toBe", resolved.value, expected, inverted);
|
|
633
1585
|
}
|
|
634
1586
|
}
|
|
1587
|
+
/** The driver's own bound for `waitUntil: 'ready'`, the fixture's default too. */
|
|
1588
|
+
const NAV_READY_TIMEOUT_MS = 15000;
|
|
1589
|
+
/** Actions that reach a page of the app. */
|
|
1590
|
+
const PAGE_ACTION = /^(?:page|view|nav)\./;
|
|
635
1591
|
function guardObject(target, fixture, path, skip = []) {
|
|
636
1592
|
const cache = new Map();
|
|
637
1593
|
return new Proxy(target, {
|
|
638
|
-
get(obj, prop
|
|
1594
|
+
get(obj, prop) {
|
|
639
1595
|
if (skip.includes(prop))
|
|
640
|
-
return Reflect.get(obj, prop,
|
|
1596
|
+
return Reflect.get(obj, prop, obj);
|
|
641
1597
|
if (cache.has(prop))
|
|
642
1598
|
return cache.get(prop);
|
|
643
|
-
const value = Reflect.get(obj, prop,
|
|
644
|
-
|
|
1599
|
+
const value = Reflect.get(obj, prop, obj);
|
|
1600
|
+
// Rong native class instances are callable objects too. Getter results
|
|
1601
|
+
// are driver namespaces; rebinding them as methods loses their members.
|
|
1602
|
+
let owner = obj;
|
|
1603
|
+
let accessor = false;
|
|
1604
|
+
while (owner) {
|
|
1605
|
+
const descriptor = Object.getOwnPropertyDescriptor(owner, prop);
|
|
1606
|
+
if (descriptor) {
|
|
1607
|
+
accessor = typeof descriptor.get === "function";
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
owner = Object.getPrototypeOf(owner);
|
|
1611
|
+
}
|
|
1612
|
+
if (typeof value === "function" && !accessor) {
|
|
645
1613
|
const name = `${path}${String(prop)}`;
|
|
646
|
-
const bound =
|
|
1614
|
+
const bound = IDEMPOTENT_READS.has(name)
|
|
1615
|
+
? (...args) => fixture.act(name, summarise(args[0]), () => fixture.readRetrying(() => value.apply(obj, args)))
|
|
1616
|
+
: (...args) => fixture.act(name, summarise(args[0]), () => value.apply(obj, args));
|
|
647
1617
|
cache.set(prop, bound);
|
|
648
1618
|
return bound;
|
|
649
1619
|
}
|
|
650
|
-
if (value && typeof value === "object") {
|
|
1620
|
+
if (value && (typeof value === "object" || typeof value === "function")) {
|
|
651
1621
|
const nested = guardObject(value, fixture, `${path}${String(prop)}.`);
|
|
652
1622
|
cache.set(prop, nested);
|
|
653
1623
|
return nested;
|
|
@@ -656,6 +1626,43 @@ function guardObject(target, fixture, path, skip = []) {
|
|
|
656
1626
|
},
|
|
657
1627
|
});
|
|
658
1628
|
}
|
|
1629
|
+
/**
|
|
1630
|
+
* A driver namespace resolved on each call instead of on read. `resolve`
|
|
1631
|
+
* yields the namespace's owner and value; a member read returns another lazy
|
|
1632
|
+
* namespace, and a call resolves the chain, then records the call as an
|
|
1633
|
+
* action. A host that lacks the tier rejects the call, never the read.
|
|
1634
|
+
*/
|
|
1635
|
+
function lazyDriver(resolve, fixture, path) {
|
|
1636
|
+
const cache = new Map();
|
|
1637
|
+
const target = function lazy() { };
|
|
1638
|
+
return new Proxy(target, {
|
|
1639
|
+
get(_, prop) {
|
|
1640
|
+
// Not a thenable: `await t.automation.desktop` must not call `then`.
|
|
1641
|
+
if (prop === "then" || typeof prop === "symbol")
|
|
1642
|
+
return undefined;
|
|
1643
|
+
if (cache.has(prop))
|
|
1644
|
+
return cache.get(prop);
|
|
1645
|
+
const member = lazyDriver(() => {
|
|
1646
|
+
const { value } = resolve();
|
|
1647
|
+
if (!value || (typeof value !== "object" && typeof value !== "function")) {
|
|
1648
|
+
throw new Error(`${path.slice(0, -1)} is not available on this host`);
|
|
1649
|
+
}
|
|
1650
|
+
return { owner: value, value: Reflect.get(value, prop, value) };
|
|
1651
|
+
}, fixture, `${path}${String(prop)}.`);
|
|
1652
|
+
cache.set(prop, member);
|
|
1653
|
+
return member;
|
|
1654
|
+
},
|
|
1655
|
+
apply(_, __, args) {
|
|
1656
|
+
const name = path.slice(0, -1);
|
|
1657
|
+
return fixture.act(name, summarise(args[0]), () => {
|
|
1658
|
+
const { owner, value } = resolve();
|
|
1659
|
+
if (typeof value !== "function")
|
|
1660
|
+
throw new TypeError(`${name} is not a function on this host`);
|
|
1661
|
+
return value.apply(owner, args);
|
|
1662
|
+
});
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
659
1666
|
/** The one detail worth showing beside an action: what it acted on. */
|
|
660
1667
|
function summarise(input) {
|
|
661
1668
|
if (input === undefined || input === null)
|
|
@@ -699,38 +1706,53 @@ function safeJson(data) {
|
|
|
699
1706
|
export function toReportError(error, step) {
|
|
700
1707
|
if (error instanceof AssertionError) {
|
|
701
1708
|
const stack = remapStack(error.stack);
|
|
1709
|
+
const cause = error;
|
|
702
1710
|
return {
|
|
1711
|
+
code: typeof cause.code === "string" ? cause.code : undefined,
|
|
1712
|
+
data: jsonData(cause.data),
|
|
703
1713
|
name: error.name,
|
|
704
1714
|
message: error.message,
|
|
705
1715
|
stack,
|
|
706
1716
|
matcher: error.matcher,
|
|
707
1717
|
expected: formatValue(error.expected),
|
|
708
1718
|
actual: formatValue(error.actual),
|
|
709
|
-
|
|
1719
|
+
difference: ["toBe", "toEqual"].includes(error.matcher)
|
|
1720
|
+
? firstDifference(error.expected, error.actual) : undefined,
|
|
1721
|
+
location: firstLocation(stack, error),
|
|
710
1722
|
step,
|
|
711
1723
|
};
|
|
712
1724
|
}
|
|
713
1725
|
if (error instanceof Error) {
|
|
714
1726
|
const stack = remapStack(error.stack);
|
|
1727
|
+
const details = error;
|
|
715
1728
|
return {
|
|
1729
|
+
code: typeof details.code === "string" ? details.code : undefined,
|
|
1730
|
+
data: jsonData(details.data),
|
|
716
1731
|
name: error.name,
|
|
717
1732
|
message: error.message,
|
|
718
1733
|
stack,
|
|
719
|
-
location: firstLocation(stack),
|
|
1734
|
+
location: firstLocation(stack, error),
|
|
720
1735
|
step,
|
|
721
1736
|
};
|
|
722
1737
|
}
|
|
723
1738
|
return { name: "Error", message: String(error), step };
|
|
724
1739
|
}
|
|
725
|
-
function
|
|
726
|
-
|
|
727
|
-
|
|
1740
|
+
function jsonData(data) {
|
|
1741
|
+
try {
|
|
1742
|
+
return data === undefined ? undefined : JSON.parse(JSON.stringify(data));
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
// Non-JSON diagnostic data must not break reporting.
|
|
1746
|
+
return undefined;
|
|
1747
|
+
}
|
|
728
1748
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1749
|
+
function firstLocation(stack, error) {
|
|
1750
|
+
// V8 starts the stack with the message; a message line (a schema issue,
|
|
1751
|
+
// a received value) must not be read as a frame.
|
|
1752
|
+
const header = `${error.name}: ${error.message}`;
|
|
1753
|
+
const frameText = stack?.startsWith(header) ? stack.slice(header.length) : stack;
|
|
1754
|
+
const frames = parseFrames(frameText);
|
|
1755
|
+
const frame = frames.length ? resolveOrigin(frames) : undefined;
|
|
1756
|
+
return frame && !isFrameworkFrame(frame.file) ? `${frame.file}:${frame.line}:${frame.column}` : undefined;
|
|
735
1757
|
}
|
|
736
1758
|
//# sourceMappingURL=fixture.js.map
|