@lingxia/test 0.18.0 → 0.20.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 +201 -23
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +1127 -157
- 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 +33 -1
- package/dist/host.js.map +1 -1
- package/dist/ids.d.ts +14 -0
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +37 -2
- 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 +32 -4
- package/dist/junit.js.map +1 -1
- package/dist/locator.d.ts +87 -9
- package/dist/locator.d.ts.map +1 -1
- package/dist/locator.js +413 -86
- 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 +195 -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 +54 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1375 -97
- 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 +7 -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 +852 -165
- 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,21 +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 { 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";
|
|
5
13
|
import { callerLocation, displayLocation, isFrameworkFrame, parseFrames, resolveOrigin } from "./ids.js";
|
|
6
|
-
import { PageLocator, sleep, testIdSelector, } from "./locator.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
37
|
this.specBudgetMs = specBudgetMs;
|
|
38
|
+
this.redactor = redactor;
|
|
19
39
|
this.steps = [];
|
|
20
40
|
/**
|
|
21
41
|
* `lx.*` members this spec's evals actually reached. Collected so the report
|
|
@@ -30,57 +50,236 @@ export class LiveFixture {
|
|
|
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
|
-
|
|
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]);
|
|
44
99
|
this.automation = new Proxy(root, {
|
|
45
100
|
get: (target, prop) => prop === "lxapp"
|
|
46
101
|
? (appId) => {
|
|
47
102
|
this.assertRunnable();
|
|
48
|
-
return this.
|
|
103
|
+
return this.appOf(appId === undefined ? this.pinned : this.refFor(appId));
|
|
49
104
|
}
|
|
50
|
-
|
|
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),
|
|
51
110
|
});
|
|
52
|
-
this.apps = { lxapp: (appId) => this.automation.lxapp(appId) };
|
|
53
111
|
}
|
|
54
112
|
get app() {
|
|
55
|
-
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
|
+
} };
|
|
56
136
|
}
|
|
57
137
|
get raw() {
|
|
58
|
-
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
|
+
}
|
|
59
245
|
}
|
|
60
246
|
step(name, body) {
|
|
61
|
-
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
|
+
}
|
|
62
257
|
const record = {
|
|
63
258
|
name,
|
|
64
|
-
|
|
259
|
+
...this.traceMeta(),
|
|
260
|
+
path: parent ? `${parent.path} > ${name}` : name,
|
|
65
261
|
status: "passed",
|
|
66
262
|
duration_ms: 0,
|
|
67
263
|
steps: [],
|
|
68
264
|
attachments: [],
|
|
69
265
|
assertions: [],
|
|
70
266
|
};
|
|
71
|
-
const parent = this.stepStack[this.stepStack.length - 1];
|
|
72
267
|
(parent ? parent.steps : this.steps).push(record);
|
|
73
268
|
this.stepStack.push(record);
|
|
74
|
-
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
this.stepStarted.set(record, started);
|
|
271
|
+
await this.emitTrace({
|
|
75
272
|
type: "step_started",
|
|
76
273
|
name,
|
|
77
274
|
path: record.path,
|
|
78
275
|
});
|
|
79
|
-
const started = Date.now();
|
|
80
276
|
try {
|
|
81
|
-
const
|
|
277
|
+
const scope = {
|
|
278
|
+
step: (childName, childBody) => this.runStep(childName, childBody, record),
|
|
279
|
+
};
|
|
280
|
+
const result = await body(scope);
|
|
82
281
|
record.duration_ms = Date.now() - started;
|
|
83
|
-
await this.
|
|
282
|
+
await this.emitTrace({
|
|
84
283
|
type: "step_finished",
|
|
85
284
|
name,
|
|
86
285
|
path: record.path,
|
|
@@ -91,10 +290,21 @@ export class LiveFixture {
|
|
|
91
290
|
}
|
|
92
291
|
catch (error) {
|
|
93
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
|
+
}
|
|
94
304
|
record.status = error instanceof TimeoutError ? "timeout" : "failed";
|
|
95
305
|
record.error = toReportError(error, record.path);
|
|
96
306
|
this.lastStepPath = record.path;
|
|
97
|
-
await this.
|
|
307
|
+
await this.emitTrace({
|
|
98
308
|
type: "step_finished",
|
|
99
309
|
name,
|
|
100
310
|
path: record.path,
|
|
@@ -105,14 +315,12 @@ export class LiveFixture {
|
|
|
105
315
|
throw error;
|
|
106
316
|
}
|
|
107
317
|
finally {
|
|
108
|
-
this.
|
|
318
|
+
this.stepStarted.delete(record);
|
|
319
|
+
const index = this.stepStack.lastIndexOf(record);
|
|
320
|
+
if (index >= 0)
|
|
321
|
+
this.stepStack.splice(index, 1);
|
|
109
322
|
}
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
get expect() {
|
|
113
|
-
const fn = ((locator) => this.locatorMatchers(locator, false));
|
|
114
|
-
fn.poll = (read, options) => this.pollMatchers(read, options, false);
|
|
115
|
-
return fn;
|
|
323
|
+
}));
|
|
116
324
|
}
|
|
117
325
|
async reject(operation, expected = {}) {
|
|
118
326
|
return this.guard(async () => {
|
|
@@ -142,7 +350,7 @@ export class LiveFixture {
|
|
|
142
350
|
].filter(Boolean).join("\n"));
|
|
143
351
|
}
|
|
144
352
|
const record = received;
|
|
145
|
-
if (expected.code !== undefined &&
|
|
353
|
+
if (expected.code !== undefined && !matchesErrorCode(received, expected.code)) {
|
|
146
354
|
this.noteAssertion({
|
|
147
355
|
matcher: "reject",
|
|
148
356
|
expected: formatValue(expected.code),
|
|
@@ -158,10 +366,10 @@ export class LiveFixture {
|
|
|
158
366
|
].filter(Boolean).join("\n"));
|
|
159
367
|
}
|
|
160
368
|
if (typeof expected.message === "string") {
|
|
161
|
-
|
|
369
|
+
check(String(record.message)).toContain(expected.message);
|
|
162
370
|
}
|
|
163
371
|
if (expected.message instanceof RegExp) {
|
|
164
|
-
|
|
372
|
+
check(String(record.message)).toMatch(expected.message);
|
|
165
373
|
}
|
|
166
374
|
this.noteAssertion({
|
|
167
375
|
matcher: "reject",
|
|
@@ -175,12 +383,114 @@ export class LiveFixture {
|
|
|
175
383
|
defer(cleanup) {
|
|
176
384
|
this.defers.push(cleanup);
|
|
177
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
|
+
}
|
|
178
485
|
async attach(name, data) {
|
|
179
486
|
await this.guard(async () => {
|
|
180
487
|
await this.attachRaw(name, data);
|
|
181
488
|
});
|
|
182
489
|
}
|
|
183
|
-
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;
|
|
184
494
|
const payload = encodeAttachPayload(data);
|
|
185
495
|
if (typeof data === "object" && data && "base64" in data && !("mimeType" in data)) {
|
|
186
496
|
if (name.endsWith(".png"))
|
|
@@ -196,18 +506,26 @@ export class LiveFixture {
|
|
|
196
506
|
else if (isPreviewable(payload.mimeType)) {
|
|
197
507
|
rememberInline(this.specId, name, { text: previewText(data, payload) });
|
|
198
508
|
}
|
|
199
|
-
const ref = { name, path, mimeType: payload.mimeType };
|
|
200
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 } : {}) };
|
|
201
512
|
(current ? current.attachments : this.attachments).push(ref);
|
|
202
513
|
return ref;
|
|
203
514
|
}
|
|
204
|
-
|
|
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") {
|
|
205
521
|
this.aborted = true;
|
|
206
522
|
this.abortError = reason;
|
|
207
|
-
this.failurePhase = "timeout";
|
|
523
|
+
this.failurePhase = as === "timeout" ? "timeout" : "body";
|
|
524
|
+
this.finishOpenSteps(reason, as);
|
|
208
525
|
for (const record of this.openActions) {
|
|
209
|
-
record.status =
|
|
526
|
+
record.status = as;
|
|
210
527
|
record.error = toReportError(reason, record.path);
|
|
528
|
+
this.noteFailedAction(record.name, record.detail, reason);
|
|
211
529
|
}
|
|
212
530
|
this.openActions.clear();
|
|
213
531
|
}
|
|
@@ -223,17 +541,108 @@ export class LiveFixture {
|
|
|
223
541
|
* trace of what it did. Retry loops silence themselves: a five-second poll
|
|
224
542
|
* would otherwise bury the report in a hundred identical rows.
|
|
225
543
|
*/
|
|
226
|
-
|
|
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) {
|
|
227
634
|
if (this.actionSilence > 0)
|
|
228
635
|
return this.guard(op);
|
|
229
636
|
// Past the cap, keep recording failures: the action that finally breaks is
|
|
230
637
|
// the one row worth having, and dropping it leaves nothing pointing at it.
|
|
231
638
|
if (this.actionCount >= MAX_ACTIONS) {
|
|
639
|
+
this.traceTruncated = true;
|
|
232
640
|
try {
|
|
233
641
|
return await this.guard(op);
|
|
234
642
|
}
|
|
235
643
|
catch (error) {
|
|
236
644
|
this.recordFailedAction(name, detail, error, 0);
|
|
645
|
+
this.noteFailedAction(name, detail, error);
|
|
237
646
|
throw error;
|
|
238
647
|
}
|
|
239
648
|
}
|
|
@@ -245,7 +654,8 @@ export class LiveFixture {
|
|
|
245
654
|
if (previous?.kind === "action" &&
|
|
246
655
|
previous.status === "passed" &&
|
|
247
656
|
previous.name === name &&
|
|
248
|
-
previous.detail === detail
|
|
657
|
+
previous.detail === detail &&
|
|
658
|
+
previous.sequence === this.eventSequence) {
|
|
249
659
|
const started = Date.now();
|
|
250
660
|
try {
|
|
251
661
|
const result = await this.guard(op);
|
|
@@ -257,6 +667,7 @@ export class LiveFixture {
|
|
|
257
667
|
// A failure is its own row: it is the one attempt worth reading.
|
|
258
668
|
const failed = {
|
|
259
669
|
name,
|
|
670
|
+
...this.traceMeta(),
|
|
260
671
|
detail,
|
|
261
672
|
kind: "action",
|
|
262
673
|
path: previous.path,
|
|
@@ -268,12 +679,14 @@ export class LiveFixture {
|
|
|
268
679
|
error: toReportError(error, previous.path),
|
|
269
680
|
};
|
|
270
681
|
siblings.push(failed);
|
|
682
|
+
this.noteFailedAction(name, detail, error);
|
|
271
683
|
throw error;
|
|
272
684
|
}
|
|
273
685
|
}
|
|
274
686
|
this.actionCount += 1;
|
|
275
687
|
const record = {
|
|
276
688
|
name,
|
|
689
|
+
...this.traceMeta(),
|
|
277
690
|
detail,
|
|
278
691
|
kind: "action",
|
|
279
692
|
path: [...this.stepStack.map((step) => step.name), name].join(" > "),
|
|
@@ -289,7 +702,7 @@ export class LiveFixture {
|
|
|
289
702
|
// body, so a record left at its optimistic default would serialise as an
|
|
290
703
|
// instant success — the hung call rendered as the fastest one in the trace.
|
|
291
704
|
this.openActions.add(record);
|
|
292
|
-
await this.
|
|
705
|
+
await this.emitTrace({ type: "step_started", name, path: record.path });
|
|
293
706
|
try {
|
|
294
707
|
const result = await this.guard(op);
|
|
295
708
|
record.duration_ms = Date.now() - started;
|
|
@@ -301,18 +714,64 @@ export class LiveFixture {
|
|
|
301
714
|
record.status = error instanceof TimeoutError ? "timeout" : "failed";
|
|
302
715
|
record.error = toReportError(error, record.path);
|
|
303
716
|
this.openActions.delete(record);
|
|
717
|
+
this.noteFailedAction(name, detail, error);
|
|
304
718
|
throw error;
|
|
305
719
|
}
|
|
306
720
|
finally {
|
|
307
|
-
await this.
|
|
721
|
+
await this.emitTrace({ type: "step_finished", name, path: record.path,
|
|
308
722
|
status: record.status, duration_ms: record.duration_ms, error: record.error });
|
|
309
723
|
}
|
|
310
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 };
|
|
768
|
+
}
|
|
311
769
|
recordFailedAction(name, detail, error, duration) {
|
|
312
770
|
const parent = this.stepStack[this.stepStack.length - 1];
|
|
313
771
|
const path = [...this.stepStack.map((step) => step.name), name].join(" > ");
|
|
314
772
|
(parent ? parent.steps : this.steps).push({
|
|
315
773
|
name,
|
|
774
|
+
...this.traceMeta(),
|
|
316
775
|
detail,
|
|
317
776
|
kind: "action",
|
|
318
777
|
path,
|
|
@@ -336,6 +795,18 @@ export class LiveFixture {
|
|
|
336
795
|
this.assertRunnable();
|
|
337
796
|
return result;
|
|
338
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
|
+
}
|
|
339
810
|
currentStepPath() {
|
|
340
811
|
const current = this.stepStack[this.stepStack.length - 1];
|
|
341
812
|
return current?.path ?? this.lastStepPath;
|
|
@@ -343,12 +814,48 @@ export class LiveFixture {
|
|
|
343
814
|
noteAssertion(entry) {
|
|
344
815
|
const record = {
|
|
345
816
|
...entry,
|
|
817
|
+
...this.traceMeta(),
|
|
346
818
|
step: this.currentStepPath(),
|
|
347
819
|
};
|
|
348
820
|
const current = this.stepStack[this.stepStack.length - 1];
|
|
349
821
|
(current ? current.assertions : this.assertions).push(record);
|
|
350
822
|
}
|
|
351
|
-
close() {
|
|
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
|
+
}
|
|
352
859
|
assertRunnable() {
|
|
353
860
|
if (this.closed)
|
|
354
861
|
throw new Error("Test fixture is closed");
|
|
@@ -365,152 +872,407 @@ export class LiveFixture {
|
|
|
365
872
|
const path = this.currentStepPath();
|
|
366
873
|
return path ? `in step ${JSON.stringify(path)}` : "";
|
|
367
874
|
}
|
|
368
|
-
wrapApp(
|
|
369
|
-
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.");
|
|
370
879
|
return {
|
|
371
|
-
page,
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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;
|
|
395
958
|
}
|
|
396
|
-
|
|
959
|
+
last = "no live instance";
|
|
397
960
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
},
|
|
401
1068
|
};
|
|
402
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
|
+
}
|
|
403
1097
|
/**
|
|
404
1098
|
* The driver's own eval default is a flat few seconds, so a call that runs
|
|
405
1099
|
* long under load fails a spec that still had most of its budget left. An
|
|
406
|
-
* eval gets
|
|
407
|
-
* all of it: a call allowed to run the full budget leaves the spec no room
|
|
408
|
-
* 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.
|
|
409
1101
|
*/
|
|
410
|
-
withEvalBudget(options) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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`);
|
|
414
1128
|
}
|
|
415
|
-
|
|
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"))));
|
|
416
1137
|
}
|
|
417
|
-
|
|
1138
|
+
/** `bound` is the immutable instance id captured by app.page(). */
|
|
1139
|
+
wrapView(page, bound) {
|
|
418
1140
|
const location = () => {
|
|
419
1141
|
const frame = callerLocation();
|
|
420
1142
|
return { source: frame.file, line: frame.line, column: frame.column };
|
|
421
1143
|
};
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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),
|
|
429
1152
|
};
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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);
|
|
436
1164
|
},
|
|
437
|
-
|
|
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
|
+
};
|
|
438
1181
|
}
|
|
439
1182
|
locator(page, selector, location, options) {
|
|
440
|
-
|
|
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());
|
|
441
1185
|
}
|
|
442
|
-
locatorMatchers(locator, inverted) {
|
|
1186
|
+
locatorMatchers(locator, inverted, message) {
|
|
1187
|
+
const withMessage = (options) => message === undefined || options?.message !== undefined ? options : { ...options, message };
|
|
443
1188
|
const self = {
|
|
444
|
-
toBeVisible: (options) => this.retryLocator(locator, "toBeVisible", inverted, options, inverted ? "not visible" : "visible"),
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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),
|
|
453
1205
|
};
|
|
454
1206
|
Object.defineProperty(self, "not", {
|
|
455
|
-
get: () => this.locatorMatchers(locator, !inverted),
|
|
1207
|
+
get: () => this.locatorMatchers(locator, !inverted, message),
|
|
1208
|
+
configurable: true,
|
|
456
1209
|
});
|
|
457
1210
|
return self;
|
|
458
1211
|
}
|
|
459
|
-
pollMatchers(read, options, inverted) {
|
|
460
|
-
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);
|
|
461
1214
|
const fixture = this;
|
|
462
1215
|
const self = {
|
|
463
1216
|
toBe: (expected) => run("toBe", expected),
|
|
464
1217
|
toEqual: (expected) => run("toEqual", expected),
|
|
465
1218
|
toContain: (expected) => run("toContain", expected),
|
|
1219
|
+
toContainEqual: (expected) => run("toContainEqual", expected),
|
|
466
1220
|
toMatch: (expected) => run("toMatch", expected),
|
|
467
1221
|
toBeTruthy: () => run("toBeTruthy"),
|
|
468
1222
|
toBeFalsy: () => run("toBeFalsy"),
|
|
469
1223
|
toBeDefined: () => run("toBeDefined"),
|
|
470
1224
|
toBeUndefined: () => run("toBeUndefined"),
|
|
471
1225
|
toBeInstanceOf: (expected) => run("toBeInstanceOf", expected),
|
|
1226
|
+
toHaveLength: (expected) => run("toHaveLength", expected),
|
|
472
1227
|
toBeGreaterThan: (expected) => run("toBeGreaterThan", expected),
|
|
473
1228
|
toBeGreaterThanOrEqual: (expected) => run("toBeGreaterThanOrEqual", expected),
|
|
474
1229
|
toBeLessThan: (expected) => run("toBeLessThan", expected),
|
|
475
1230
|
toBeLessThanOrEqual: (expected) => run("toBeLessThanOrEqual", expected),
|
|
476
1231
|
};
|
|
477
1232
|
Object.defineProperty(self, "not", {
|
|
478
|
-
get: () => fixture.pollMatchers(read, options, !inverted),
|
|
1233
|
+
get: () => fixture.pollMatchers(read, options, !inverted, api),
|
|
1234
|
+
configurable: true,
|
|
479
1235
|
});
|
|
480
1236
|
return self;
|
|
481
1237
|
}
|
|
482
|
-
|
|
483
|
-
|
|
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 () => {
|
|
484
1242
|
const frame = callerLocation();
|
|
485
1243
|
const location = { source: frame.file, line: frame.line, column: frame.column };
|
|
486
|
-
const
|
|
1244
|
+
const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
|
|
487
1245
|
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
488
|
-
const
|
|
1246
|
+
const context = () => this.deadlineContext(inverted ? `not.${matcher}` : matcher, location);
|
|
489
1247
|
let lastResolved;
|
|
490
1248
|
let lastError;
|
|
491
1249
|
pushAssertionSilence();
|
|
492
1250
|
this.silenceActions();
|
|
493
1251
|
try {
|
|
494
|
-
while (
|
|
1252
|
+
while (!deadline.expired()) {
|
|
495
1253
|
try {
|
|
496
|
-
lastResolved = await resolveLocator(locator);
|
|
1254
|
+
lastResolved = await resolveLocator(locator, deadline, context, expected instanceof AttributeExpectation ? [expected.name] : []);
|
|
497
1255
|
matchLocator(locator, lastResolved, matcher, expected, inverted);
|
|
498
1256
|
this.noteAssertion({
|
|
499
1257
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
500
1258
|
expected: formatValue(expected),
|
|
501
|
-
actual: formatValue(locatorActual(matcher, lastResolved)),
|
|
1259
|
+
actual: formatValue(locatorActual(matcher, lastResolved, expected)),
|
|
502
1260
|
passed: true,
|
|
1261
|
+
target,
|
|
1262
|
+
message: options?.message,
|
|
1263
|
+
location: displayLocation(location.source, location.line, location.column),
|
|
1264
|
+
duration_ms: deadline.elapsed(),
|
|
503
1265
|
});
|
|
504
1266
|
return;
|
|
505
1267
|
}
|
|
506
1268
|
catch (error) {
|
|
507
|
-
if (error instanceof TimeoutError || this.aborted)
|
|
1269
|
+
if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
|
|
508
1270
|
throw error;
|
|
509
1271
|
lastError = error;
|
|
510
1272
|
}
|
|
511
|
-
if (
|
|
1273
|
+
if (deadline.expired())
|
|
512
1274
|
break;
|
|
513
|
-
await sleep(interval);
|
|
1275
|
+
await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
|
|
514
1276
|
if (this.aborted && this.abortError)
|
|
515
1277
|
throw this.abortError;
|
|
516
1278
|
}
|
|
@@ -519,50 +1281,61 @@ export class LiveFixture {
|
|
|
519
1281
|
popAssertionSilence();
|
|
520
1282
|
this.resumeActions();
|
|
521
1283
|
}
|
|
522
|
-
const duration =
|
|
1284
|
+
const duration = deadline.elapsed();
|
|
1285
|
+
const miss = lastResolved && locator instanceof PageLocator ? locator.missText(lastResolved) : undefined;
|
|
523
1286
|
throw this.retryFailure({
|
|
1287
|
+
clampNote: deadline.clampNote(),
|
|
524
1288
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
525
1289
|
expected,
|
|
526
|
-
actual: locatorActual(matcher, lastResolved),
|
|
1290
|
+
actual: locatorActual(matcher, lastResolved, expected),
|
|
527
1291
|
duration,
|
|
528
1292
|
location,
|
|
529
1293
|
lastError,
|
|
530
|
-
extra:
|
|
1294
|
+
extra: miss,
|
|
1295
|
+
locator: locator instanceof PageLocator ? locator.boundPage : null,
|
|
1296
|
+
target,
|
|
1297
|
+
message: options?.message,
|
|
531
1298
|
});
|
|
532
|
-
});
|
|
1299
|
+
}), `expect(${target}).${assertion}`);
|
|
533
1300
|
}
|
|
534
|
-
|
|
535
|
-
|
|
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 () => {
|
|
536
1305
|
const frame = callerLocation();
|
|
537
1306
|
const location = { source: frame.file, line: frame.line, column: frame.column };
|
|
538
|
-
const
|
|
1307
|
+
const deadline = new ActionDeadline(options?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS, this.budgetRoom());
|
|
539
1308
|
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
540
|
-
const
|
|
1309
|
+
const context = () => this.deadlineContext(`${api} ${inverted ? "not." : ""}${matcher}`, location);
|
|
541
1310
|
let lastActual;
|
|
542
1311
|
let lastError;
|
|
543
1312
|
pushAssertionSilence();
|
|
544
1313
|
this.silenceActions();
|
|
545
1314
|
try {
|
|
546
|
-
while (
|
|
1315
|
+
while (!deadline.expired()) {
|
|
547
1316
|
try {
|
|
548
|
-
lastActual = await read
|
|
1317
|
+
lastActual = await deadline.call(`${api} read`, read, context);
|
|
549
1318
|
applyMatcher(matcher, lastActual, expected, inverted);
|
|
550
1319
|
this.noteAssertion({
|
|
551
1320
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
552
1321
|
expected: formatValue(expected),
|
|
553
1322
|
actual: formatValue(lastActual),
|
|
554
1323
|
passed: true,
|
|
1324
|
+
target: source,
|
|
1325
|
+
message: options?.message,
|
|
1326
|
+
location: displayLocation(location.source, location.line, location.column),
|
|
1327
|
+
duration_ms: deadline.elapsed(),
|
|
555
1328
|
});
|
|
556
1329
|
return;
|
|
557
1330
|
}
|
|
558
1331
|
catch (error) {
|
|
559
|
-
if (error instanceof TimeoutError || this.aborted)
|
|
1332
|
+
if (error instanceof TimeoutError || this.aborted || !isRetryableReadError(error))
|
|
560
1333
|
throw error;
|
|
561
1334
|
lastError = error;
|
|
562
1335
|
}
|
|
563
|
-
if (
|
|
1336
|
+
if (deadline.expired())
|
|
564
1337
|
break;
|
|
565
|
-
await sleep(interval);
|
|
1338
|
+
await sleep(Math.min(interval, Math.max(1, deadline.remaining())));
|
|
566
1339
|
if (this.aborted && this.abortError)
|
|
567
1340
|
throw this.abortError;
|
|
568
1341
|
}
|
|
@@ -572,14 +1345,24 @@ export class LiveFixture {
|
|
|
572
1345
|
this.resumeActions();
|
|
573
1346
|
}
|
|
574
1347
|
throw this.retryFailure({
|
|
1348
|
+
clampNote: deadline.clampNote(),
|
|
575
1349
|
matcher: inverted ? `not.${matcher}` : matcher,
|
|
576
1350
|
expected,
|
|
577
1351
|
actual: lastActual,
|
|
578
|
-
duration:
|
|
1352
|
+
duration: deadline.elapsed(),
|
|
579
1353
|
location,
|
|
580
1354
|
lastError,
|
|
1355
|
+
target: source,
|
|
1356
|
+
message: options?.message,
|
|
581
1357
|
});
|
|
582
|
-
});
|
|
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");
|
|
583
1366
|
}
|
|
584
1367
|
retryFailure(input) {
|
|
585
1368
|
const where = displayLocation(input.location.source, input.location.line, input.location.column);
|
|
@@ -589,11 +1372,13 @@ export class LiveFixture {
|
|
|
589
1372
|
? input.lastError.message
|
|
590
1373
|
: undefined;
|
|
591
1374
|
const lines = [
|
|
1375
|
+
input.message,
|
|
592
1376
|
`Timed out after ${input.duration}ms retrying ${input.matcher}.`,
|
|
593
1377
|
input.extra,
|
|
594
1378
|
`Expected: ${formatValue(input.expected)}`,
|
|
595
1379
|
`Received: ${formatValue(input.actual)}`,
|
|
596
1380
|
`Retried for ${input.duration}ms`,
|
|
1381
|
+
input.clampNote,
|
|
597
1382
|
`at ${where}`,
|
|
598
1383
|
this.stepPathLine(),
|
|
599
1384
|
last && last !== `Expected: ${formatValue(input.expected)}` ? last : undefined,
|
|
@@ -603,18 +1388,119 @@ export class LiveFixture {
|
|
|
603
1388
|
expected: formatValue(input.expected),
|
|
604
1389
|
actual: formatValue(input.actual),
|
|
605
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,
|
|
606
1397
|
});
|
|
607
|
-
|
|
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)`);
|
|
608
1429
|
}
|
|
1430
|
+
return id;
|
|
609
1431
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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";
|
|
614
1442
|
}
|
|
615
|
-
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) {
|
|
616
1494
|
if (!resolved)
|
|
617
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
|
+
}
|
|
618
1504
|
if (matcher === "toBeHidden")
|
|
619
1505
|
return !resolved.visible;
|
|
620
1506
|
if (matcher === "toBeAttached")
|
|
@@ -651,6 +1537,35 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
651
1537
|
}
|
|
652
1538
|
return;
|
|
653
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
|
+
}
|
|
654
1569
|
if (matcher === "toHaveCount") {
|
|
655
1570
|
applyMatcher("toBe", resolved.count, expected, inverted);
|
|
656
1571
|
return;
|
|
@@ -659,7 +1574,7 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
659
1574
|
if (expected instanceof RegExp)
|
|
660
1575
|
applyMatcher("toMatch", resolved.text, expected, inverted);
|
|
661
1576
|
else
|
|
662
|
-
applyMatcher("toBe", resolved.text, expected, inverted);
|
|
1577
|
+
applyMatcher("toBe", resolved.text, typeof expected === "string" ? normalizeText(expected) : expected, inverted);
|
|
663
1578
|
return;
|
|
664
1579
|
}
|
|
665
1580
|
if (matcher === "toHaveValue") {
|
|
@@ -669,6 +1584,10 @@ function matchLocator(locator, resolved, matcher, expected, inverted) {
|
|
|
669
1584
|
applyMatcher("toBe", resolved.value, expected, inverted);
|
|
670
1585
|
}
|
|
671
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)\./;
|
|
672
1591
|
function guardObject(target, fixture, path, skip = []) {
|
|
673
1592
|
const cache = new Map();
|
|
674
1593
|
return new Proxy(target, {
|
|
@@ -692,7 +1611,9 @@ function guardObject(target, fixture, path, skip = []) {
|
|
|
692
1611
|
}
|
|
693
1612
|
if (typeof value === "function" && !accessor) {
|
|
694
1613
|
const name = `${path}${String(prop)}`;
|
|
695
|
-
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));
|
|
696
1617
|
cache.set(prop, bound);
|
|
697
1618
|
return bound;
|
|
698
1619
|
}
|
|
@@ -705,6 +1626,43 @@ function guardObject(target, fixture, path, skip = []) {
|
|
|
705
1626
|
},
|
|
706
1627
|
});
|
|
707
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
|
+
}
|
|
708
1666
|
/** The one detail worth showing beside an action: what it acted on. */
|
|
709
1667
|
function summarise(input) {
|
|
710
1668
|
if (input === undefined || input === null)
|
|
@@ -748,40 +1706,52 @@ function safeJson(data) {
|
|
|
748
1706
|
export function toReportError(error, step) {
|
|
749
1707
|
if (error instanceof AssertionError) {
|
|
750
1708
|
const stack = remapStack(error.stack);
|
|
1709
|
+
const cause = error;
|
|
751
1710
|
return {
|
|
1711
|
+
code: typeof cause.code === "string" ? cause.code : undefined,
|
|
1712
|
+
data: jsonData(cause.data),
|
|
752
1713
|
name: error.name,
|
|
753
1714
|
message: error.message,
|
|
754
1715
|
stack,
|
|
755
1716
|
matcher: error.matcher,
|
|
756
1717
|
expected: formatValue(error.expected),
|
|
757
1718
|
actual: formatValue(error.actual),
|
|
758
|
-
|
|
1719
|
+
difference: ["toBe", "toEqual"].includes(error.matcher)
|
|
1720
|
+
? firstDifference(error.expected, error.actual) : undefined,
|
|
1721
|
+
location: firstLocation(stack, error),
|
|
759
1722
|
step,
|
|
760
1723
|
};
|
|
761
1724
|
}
|
|
762
1725
|
if (error instanceof Error) {
|
|
763
1726
|
const stack = remapStack(error.stack);
|
|
764
1727
|
const details = error;
|
|
765
|
-
let data;
|
|
766
|
-
try {
|
|
767
|
-
if (details.data !== undefined)
|
|
768
|
-
data = JSON.parse(JSON.stringify(details.data));
|
|
769
|
-
}
|
|
770
|
-
catch { /* Non-JSON diagnostic data must not break reporting. */ }
|
|
771
1728
|
return {
|
|
772
1729
|
code: typeof details.code === "string" ? details.code : undefined,
|
|
773
|
-
data,
|
|
1730
|
+
data: jsonData(details.data),
|
|
774
1731
|
name: error.name,
|
|
775
1732
|
message: error.message,
|
|
776
1733
|
stack,
|
|
777
|
-
location: firstLocation(stack),
|
|
1734
|
+
location: firstLocation(stack, error),
|
|
778
1735
|
step,
|
|
779
1736
|
};
|
|
780
1737
|
}
|
|
781
1738
|
return { name: "Error", message: String(error), step };
|
|
782
1739
|
}
|
|
783
|
-
function
|
|
784
|
-
|
|
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
|
+
}
|
|
1748
|
+
}
|
|
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);
|
|
785
1755
|
const frame = frames.length ? resolveOrigin(frames) : undefined;
|
|
786
1756
|
return frame && !isFrameworkFrame(frame.file) ? `${frame.file}:${frame.line}:${frame.column}` : undefined;
|
|
787
1757
|
}
|