@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/runtime.js
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { matchesErrorCode } from "./deadline.js";
|
|
2
|
+
import { expect, setExpectScope, setStrayAssertionSink } from "./expect.js";
|
|
3
|
+
import { LiveFixture, SkipSignal, TimeoutError, toReportError } from "./fixture.js";
|
|
4
|
+
import { formatValue } from "./format.js";
|
|
3
5
|
import { attachText, resolveHost, warnVersionSkew } from "./host.js";
|
|
4
|
-
import { captureFrames, fileStem, resolveOrigin, slugTitle } from "./ids.js";
|
|
6
|
+
import { captureFrames, fileStem, isUnattributed, resolveOrigin, resolveOwner, slugTitle } from "./ids.js";
|
|
5
7
|
import { renderJUnit } from "./junit.js";
|
|
8
|
+
import { resetMocks } from "./mock.js";
|
|
9
|
+
import { createRedactor } from "./redact.js";
|
|
6
10
|
import { clearInline, countStatuses, renderHtml } from "./report.js";
|
|
11
|
+
import { coverageSummary, parseManifest } from "./coverage.js";
|
|
12
|
+
import { ContractError, ContractLedger, parseOpenApiControl, setActiveOpenApi } from "./openapi.js";
|
|
13
|
+
import { matchesTags, parseTagFilter, tagSummary, validateTags } from "./tags.js";
|
|
14
|
+
import { SCREEN_LOCKED_NOTE, VISIBILITY_PROBE_BUDGET_MS, VISIBILITY_PROBE_SCRIPT, hiddenCause, pageVisibility } from "./locator.js";
|
|
7
15
|
import { DEFAULT_SPEC_TIMEOUT_MS, FORENSICS_BUDGET_MS, MAX_DEFER_BUDGET_MS, PACKAGE_NAME, VERSION, WEDGED_DEFER_BUDGET_MS, } from "./version.js";
|
|
16
|
+
import { cancelTimersOf, describePending, installPendingTracker, pendingOf, restoreRawAuthority, revokeGrant, revokeRawAuthority, runnerClearTimeout, runnerSetTimeout, setPendingOwner, settleCallsOf, trackAutomationRoot, uninstallPendingTracker } from "./pending.js";
|
|
17
|
+
function copyStepRecord(step) {
|
|
18
|
+
return {
|
|
19
|
+
...step,
|
|
20
|
+
steps: step.steps.map(copyStepRecord),
|
|
21
|
+
assertions: step.assertions.map((entry) => ({ ...entry })),
|
|
22
|
+
attachments: step.attachments.map((entry) => ({ ...entry })),
|
|
23
|
+
error: step.error ? { ...step.error } : undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
8
26
|
const specs = [];
|
|
9
27
|
const hooks = [];
|
|
10
28
|
const afterHooks = [];
|
|
11
29
|
const resetHooks = [];
|
|
12
|
-
|
|
30
|
+
/** Why a companion does not reset its mock handlers, said once per run. */
|
|
31
|
+
const mockResetReasons = new Set();
|
|
32
|
+
const fileConfigs = [];
|
|
13
33
|
let forceRelaunchNext = false;
|
|
14
34
|
let trackSurface = false;
|
|
15
35
|
/**
|
|
@@ -30,37 +50,127 @@ function parseArgs(optionsOrBody, maybeBody) {
|
|
|
30
50
|
}
|
|
31
51
|
return { options: optionsOrBody, body: maybeBody };
|
|
32
52
|
}
|
|
53
|
+
/** `restoreProfile: { keep }` → the keep globs; `true`/absent → none. */
|
|
54
|
+
function restoreProfileKeep(option) {
|
|
55
|
+
if (option === undefined || typeof option === "boolean")
|
|
56
|
+
return undefined;
|
|
57
|
+
const keep = option && typeof option === "object" ? option.keep : undefined;
|
|
58
|
+
if (!Array.isArray(keep) || keep.some((glob) => typeof glob !== "string" || glob.length === 0)) {
|
|
59
|
+
throw new TypeError("restoreProfile must be true or { keep: string[] } of non-empty storage key globs");
|
|
60
|
+
}
|
|
61
|
+
return [...keep];
|
|
62
|
+
}
|
|
63
|
+
function validateRequires(requires, where) {
|
|
64
|
+
if (requires === undefined)
|
|
65
|
+
return { args: [], openapi: false };
|
|
66
|
+
if (!requires || typeof requires !== "object")
|
|
67
|
+
throw new TypeError(`${where} requires must be an object`);
|
|
68
|
+
const { args, openapi } = requires;
|
|
69
|
+
if (args !== undefined && (!Array.isArray(args) || args.some((key) => typeof key !== "string" || key.length === 0))) {
|
|
70
|
+
throw new TypeError(`${where} requires.args must be an array of non-empty --arg keys`);
|
|
71
|
+
}
|
|
72
|
+
if (openapi !== undefined && typeof openapi !== "boolean")
|
|
73
|
+
throw new TypeError(`${where} requires.openapi must be a boolean`);
|
|
74
|
+
return { args: [...new Set(args ?? [])], openapi: openapi === true };
|
|
75
|
+
}
|
|
76
|
+
/** Checks what `spec()` and `spec.configure()` accept alike; throws on the first problem. */
|
|
77
|
+
function validateOptions(options, where) {
|
|
78
|
+
restoreProfileKeep(options.restoreProfile);
|
|
79
|
+
const start = options.start;
|
|
80
|
+
if (start !== undefined) {
|
|
81
|
+
if (!start || typeof start !== "object" || typeof start.page !== "string" || start.page.length === 0) {
|
|
82
|
+
throw new TypeError(`${where} start must be { page, query? } with a configured page name`);
|
|
83
|
+
}
|
|
84
|
+
if (start.query !== undefined && (!start.query || typeof start.query !== "object" || Array.isArray(start.query))) {
|
|
85
|
+
throw new TypeError(`${where} start.query must be an object`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [name, value] of Object.entries({ timeout: options.timeout, timeoutCleanup: options.timeoutCleanup })) {
|
|
89
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0))
|
|
90
|
+
throw new TypeError(`${where} ${name} must be a positive finite number`);
|
|
91
|
+
}
|
|
92
|
+
validateTags(options.tags, where);
|
|
93
|
+
validateRequires(options.requires, where);
|
|
94
|
+
if (options.covers !== undefined && (!Array.isArray(options.covers) || options.covers.some((id) => typeof id !== "string"))) {
|
|
95
|
+
throw new TypeError(`${where} covers must be an array of strings`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The spec's settings: its own options over its file's `spec.configure()`
|
|
100
|
+
* defaults. `tags`, `covers` and `requires` add to the file's.
|
|
101
|
+
*/
|
|
102
|
+
function settle(item, file) {
|
|
103
|
+
const own = item.own;
|
|
104
|
+
const pick = (key) => own[key] !== undefined ? own[key] : file?.[key];
|
|
105
|
+
const restoreProfile = pick("restoreProfile");
|
|
106
|
+
const restoreKeep = restoreProfileKeep(restoreProfile);
|
|
107
|
+
const fileRequires = validateRequires(file?.requires, "spec.configure()");
|
|
108
|
+
const ownRequires = validateRequires(own.requires, `spec ${JSON.stringify(item.title)}`);
|
|
109
|
+
item.covers = [...new Set([...(file?.covers ?? []), ...(own.covers ?? [])])];
|
|
110
|
+
item.tags = [...new Set([...validateTags(file?.tags, "spec.configure()"), ...validateTags(own.tags, `spec ${JSON.stringify(item.title)}`)])];
|
|
111
|
+
item.timeout = pick("timeout") ?? DEFAULT_SPEC_TIMEOUT_MS;
|
|
112
|
+
item.timeoutCleanup = pick("timeoutCleanup");
|
|
113
|
+
item.start = pick("start");
|
|
114
|
+
item.restoreProfile = restoreProfile === true || restoreKeep !== undefined;
|
|
115
|
+
item.restoreKeep = restoreKeep;
|
|
116
|
+
item.app = pick("app");
|
|
117
|
+
item.forensics = pick("forensics") !== false;
|
|
118
|
+
item.reason = pick("reason");
|
|
119
|
+
item.requires = {
|
|
120
|
+
args: [...new Set([...fileRequires.args, ...ownRequires.args])],
|
|
121
|
+
openapi: fileRequires.openapi || ownRequires.openapi,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Why the run cannot give the spec what it `requires`, or `undefined`. */
|
|
125
|
+
function unmetRequirements(item, args, openapi) {
|
|
126
|
+
const missing = item.requires.args.filter((key) => args[key] === undefined);
|
|
127
|
+
const needs = [];
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
needs.push(missing.map((key) => `--arg ${key}=<value>`).join(", ") + " (or --secret-arg)");
|
|
130
|
+
}
|
|
131
|
+
if (item.requires.openapi && !openapi)
|
|
132
|
+
needs.push("--openapi <document>");
|
|
133
|
+
return needs.length === 0 ? undefined : `Not run: requires ${needs.join(" and ")}.`;
|
|
134
|
+
}
|
|
33
135
|
function register(annotation, title, optionsOrBody, maybeBody) {
|
|
34
136
|
if (typeof title !== "string" || title.length === 0) {
|
|
35
137
|
throw new TypeError("spec() requires a non-empty title");
|
|
36
138
|
}
|
|
37
139
|
const { options, body } = parseArgs(optionsOrBody, maybeBody);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
140
|
+
const expected = options.expected;
|
|
141
|
+
if (expected !== undefined) {
|
|
142
|
+
if (annotation !== "fail")
|
|
143
|
+
throw new TypeError("`expected` is only meaningful for spec.fail()");
|
|
144
|
+
if (!expected || typeof expected !== "object")
|
|
145
|
+
throw new TypeError("spec.fail `expected` must be an object");
|
|
146
|
+
if (expected.code === undefined && expected.message === undefined) {
|
|
147
|
+
throw new TypeError("spec.fail `expected` needs a code or a message");
|
|
148
|
+
}
|
|
41
149
|
}
|
|
150
|
+
validateOptions(options, `spec ${JSON.stringify(title)}`);
|
|
42
151
|
// The bundle map is installed after the modules run, so keep the raw frames;
|
|
43
152
|
// the authored file is only knowable once the run starts. `frames[0]` is a
|
|
44
153
|
// frame inside this package, identical for every caller, so it can order
|
|
45
154
|
// registrations but must never stand in for identity.
|
|
46
155
|
const frames = captureFrames();
|
|
47
|
-
const
|
|
48
|
-
fileCounts.set(String(indexInFile), indexInFile);
|
|
49
|
-
specs.push({
|
|
156
|
+
const item = {
|
|
50
157
|
title,
|
|
51
158
|
id: options.id,
|
|
52
|
-
covers: [
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
159
|
+
covers: [],
|
|
160
|
+
tags: [],
|
|
161
|
+
timeout: DEFAULT_SPEC_TIMEOUT_MS,
|
|
162
|
+
restoreProfile: false,
|
|
163
|
+
forensics: true,
|
|
164
|
+
requires: { args: [], openapi: false },
|
|
165
|
+
own: { ...options },
|
|
166
|
+
expected,
|
|
59
167
|
annotation,
|
|
60
168
|
body,
|
|
61
169
|
frames,
|
|
62
|
-
|
|
63
|
-
|
|
170
|
+
};
|
|
171
|
+
// Settled again at run start, with the file's defaults.
|
|
172
|
+
settle(item, undefined);
|
|
173
|
+
specs.push(item);
|
|
64
174
|
}
|
|
65
175
|
const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
|
|
66
176
|
register("default", title, optionsOrBody, maybeBody);
|
|
@@ -96,17 +206,84 @@ const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
|
|
|
96
206
|
throw new TypeError("spec.beforeEach() requires a function");
|
|
97
207
|
hooks.push({ frames: captureFrames(), fn });
|
|
98
208
|
},
|
|
209
|
+
configure(options) {
|
|
210
|
+
if (!options || typeof options !== "object")
|
|
211
|
+
throw new TypeError("spec.configure() requires an options object");
|
|
212
|
+
if (options.id !== undefined)
|
|
213
|
+
throw new TypeError("spec.configure() cannot set `id`: ids are per spec");
|
|
214
|
+
validateOptions(options, "spec.configure()");
|
|
215
|
+
fileConfigs.push({ frames: captureFrames(), options: { ...options } });
|
|
216
|
+
},
|
|
99
217
|
});
|
|
218
|
+
/** Two `spec.configure()` calls of one file: later keys win; lists add up. */
|
|
219
|
+
function mergeFileOptions(previous, next) {
|
|
220
|
+
if (!previous)
|
|
221
|
+
return { ...next };
|
|
222
|
+
const merged = { ...previous, ...Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)) };
|
|
223
|
+
merged.tags = [...new Set([...(previous.tags ?? []), ...(next.tags ?? [])])];
|
|
224
|
+
merged.covers = [...new Set([...(previous.covers ?? []), ...(next.covers ?? [])])];
|
|
225
|
+
merged.requires = {
|
|
226
|
+
args: [...new Set([...(previous.requires?.args ?? []), ...(next.requires?.args ?? [])])],
|
|
227
|
+
openapi: previous.requires?.openapi === true || next.requires?.openapi === true,
|
|
228
|
+
};
|
|
229
|
+
return merged;
|
|
230
|
+
}
|
|
100
231
|
function sourceOf(item) {
|
|
101
232
|
const origin = resolveOrigin(item.frames);
|
|
102
233
|
return { file: origin.file, line: origin.line };
|
|
103
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Every spec, `spec.configure()` and hook must resolve to the file that
|
|
237
|
+
* registered it: a file-less spec loses its file's tags and hooks without a
|
|
238
|
+
* word, so the run stops and names what it could not place.
|
|
239
|
+
*/
|
|
240
|
+
function assertAttributed() {
|
|
241
|
+
const lost = [];
|
|
242
|
+
for (const item of specs) {
|
|
243
|
+
const origin = resolveOrigin(item.frames);
|
|
244
|
+
if (isUnattributed(origin.file))
|
|
245
|
+
lost.push(`spec ${JSON.stringify(item.title)} (${origin.file}:${origin.line})`);
|
|
246
|
+
}
|
|
247
|
+
const specFiles = new Set(specs.map((item) => sourceOf(item).file));
|
|
248
|
+
for (const [kind, list] of [["configure", fileConfigs], ["beforeEach", hooks], ["afterEach", afterHooks], ["reset", resetHooks]]) {
|
|
249
|
+
for (const entry of list) {
|
|
250
|
+
const owner = resolveOwner(entry.frames, specFiles);
|
|
251
|
+
if (isUnattributed(owner.file))
|
|
252
|
+
lost.push(`spec.${kind}() (${owner.file}:${owner.line})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (lost.length === 0)
|
|
256
|
+
return;
|
|
257
|
+
const shown = lost.slice(0, 10).join(", ") + (lost.length > 10 ? `, and ${lost.length - 10} more` : "");
|
|
258
|
+
throw new Error(`Cannot tell which file registered ${shown}: the bundle source map has no position for the call. ` +
|
|
259
|
+
"Its file's tags and hooks would not apply, so the run stops. Please report this with the spec file and the lxdev version.");
|
|
260
|
+
}
|
|
261
|
+
function needsGeneratedId(item) {
|
|
262
|
+
return !(item.id && item.id.length > 0) && slugTitle(item.title) === undefined;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Number the specs that need a generated id, per file and in registration
|
|
266
|
+
* order, so adding a spec to one file never renames a spec in another.
|
|
267
|
+
*/
|
|
268
|
+
function assignFileIndexes() {
|
|
269
|
+
const counts = new Map();
|
|
270
|
+
for (const item of specs) {
|
|
271
|
+
if (!needsGeneratedId(item))
|
|
272
|
+
continue;
|
|
273
|
+
const file = sourceOf(item).file;
|
|
274
|
+
const next = (counts.get(file) ?? 0) + 1;
|
|
275
|
+
counts.set(file, next);
|
|
276
|
+
item.indexInFile = next;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
104
279
|
function resolvedId(item) {
|
|
105
280
|
if (item.id && item.id.length > 0)
|
|
106
281
|
return item.id;
|
|
107
282
|
const slug = slugTitle(item.title);
|
|
108
283
|
if (slug)
|
|
109
284
|
return slug;
|
|
285
|
+
if (item.indexInFile === undefined)
|
|
286
|
+
assignFileIndexes();
|
|
110
287
|
return `${fileStem(sourceOf(item).file)}-${item.indexInFile}`;
|
|
111
288
|
}
|
|
112
289
|
/** Report group: the authored file path, trimmed to the project-relative part. */
|
|
@@ -116,28 +293,219 @@ function suiteOf(file) {
|
|
|
116
293
|
const cut = trimmed.lastIndexOf("/tests/");
|
|
117
294
|
return cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
|
|
118
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* During a run, the host's own `lx.automation`: the global is replaced with
|
|
298
|
+
* one that hands spec code `rawAutomation()`'s fenced root, so a spec cannot
|
|
299
|
+
* reach around its grant through the global.
|
|
300
|
+
*/
|
|
301
|
+
let hostAutomation;
|
|
302
|
+
function fenceGlobalAutomation() {
|
|
303
|
+
const lx = globalThis.lx;
|
|
304
|
+
if (hostAutomation || !lx || typeof lx.automation !== "function")
|
|
305
|
+
return;
|
|
306
|
+
const automation = lx.automation;
|
|
307
|
+
hostAutomation = { lx: lx, automation };
|
|
308
|
+
try {
|
|
309
|
+
lx.automation = () => trackAutomationRoot(automation.call(lx));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
hostAutomation = undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function unfenceGlobalAutomation() {
|
|
316
|
+
if (!hostAutomation)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
hostAutomation.lx.automation = hostAutomation.automation;
|
|
320
|
+
}
|
|
321
|
+
catch { /* the context is going away */ }
|
|
322
|
+
hostAutomation = undefined;
|
|
323
|
+
}
|
|
119
324
|
function automationRoot() {
|
|
325
|
+
if (hostAutomation)
|
|
326
|
+
return hostAutomation.automation.call(hostAutomation.lx);
|
|
327
|
+
// The host injects the root as `lx.automation()` in the test context; that
|
|
328
|
+
// binding is the transport, not a typed global of the test program, so a
|
|
329
|
+
// spec that type-checks app Logic sees one meaning of `lx`: the app's.
|
|
120
330
|
const lx = globalThis.lx;
|
|
121
331
|
if (!lx || typeof lx.automation !== "function") {
|
|
122
|
-
throw new Error("
|
|
332
|
+
throw new Error("The automation root is not available: rawAutomation() runs only inside an lxdev test run");
|
|
123
333
|
}
|
|
124
334
|
return lx.automation();
|
|
125
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* The untraced automation root of this run, with host authority. Specs drive
|
|
338
|
+
* the app through `t.app` / `t.automation`, which trace every call and stop
|
|
339
|
+
* with the spec; use this only where no fixture exists (a setup file that
|
|
340
|
+
* waits for the app before the first spec) or to reach the raw driver, e.g.
|
|
341
|
+
* `rawAutomation().lxapp().eval({ script })`.
|
|
342
|
+
*/
|
|
343
|
+
export function rawAutomation() {
|
|
344
|
+
// Refuses once the run has revoked spec code's automation access.
|
|
345
|
+
return trackAutomationRoot(automationRoot());
|
|
346
|
+
}
|
|
126
347
|
function pinApp(appId) {
|
|
127
348
|
const automation = automationRoot();
|
|
128
349
|
return appId ? automation.lxapp(appId) : automation.lxapp();
|
|
129
350
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
351
|
+
/** How long a closing app under test gets to finish closing before it is reopened. */
|
|
352
|
+
const REOPEN_CLOSING_WAIT_MS = 5000;
|
|
353
|
+
/**
|
|
354
|
+
* Reopen the app under test when it is no longer running, and wait for its
|
|
355
|
+
* home page. Resolves `undefined` when it was running (or cannot be told);
|
|
356
|
+
* otherwise what happened, with the error if reopening failed.
|
|
357
|
+
*/
|
|
358
|
+
async function reopenAppUnderTest(appId) {
|
|
359
|
+
if (!appId)
|
|
360
|
+
return undefined;
|
|
361
|
+
let root;
|
|
362
|
+
try {
|
|
363
|
+
root = automationRoot();
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const status = async () => {
|
|
369
|
+
const apps = await root.lxapps.list();
|
|
370
|
+
return apps.find((app) => app.appId === appId)?.status;
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
let current = await status();
|
|
374
|
+
const deadline = Date.now() + REOPEN_CLOSING_WAIT_MS;
|
|
375
|
+
while (current === "closing" && Date.now() < deadline) {
|
|
376
|
+
await new Promise((resolve) => { runnerSetTimeout(resolve, 100); });
|
|
377
|
+
current = await status();
|
|
378
|
+
}
|
|
379
|
+
if (current === "opened" || current === "opening" || current === "restarting")
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// A host without `lxapps` cannot say; leave the app alone.
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await root.lxapps.open({ appId });
|
|
388
|
+
await relaunchHome(root.lxapp(appId));
|
|
389
|
+
return { appId };
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
return { appId, error: String(error?.message ?? error) };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** How long recovering the app under test may take before the run stops. */
|
|
396
|
+
const RECOVERY_BUDGET_MS = 20000;
|
|
397
|
+
const RECOVER_COMMAND = "lxdev lxapp restart";
|
|
398
|
+
/**
|
|
399
|
+
* After a spec left work pending: the same recovery a spec start and the run
|
|
400
|
+
* start use — reopen the app if it is closed, then relaunch its home page.
|
|
401
|
+
* Resolves `undefined` when the app is back, otherwise why it is not.
|
|
402
|
+
*/
|
|
403
|
+
async function recoverAppUnderTest(appId) {
|
|
404
|
+
const reopened = await reopenAppUnderTest(appId);
|
|
405
|
+
if (reopened?.error)
|
|
406
|
+
return reopened.error;
|
|
407
|
+
try {
|
|
408
|
+
await within(relaunchHome(pinApp(appId)), RECOVERY_BUDGET_MS, `relaunching the home page did not finish within ${RECOVERY_BUDGET_MS}ms`);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
return String(error?.message ?? error);
|
|
413
|
+
}
|
|
134
414
|
}
|
|
135
|
-
|
|
415
|
+
/** Relaunch on the spec's `start` page, or the app's home page. */
|
|
416
|
+
async function relaunchHome(app, start) {
|
|
417
|
+
const page = start?.page ?? (await app.pages())[0]?.name ?? "home";
|
|
418
|
+
try {
|
|
419
|
+
await app.nav.relaunch({ page, ...(start?.query ? { query: start.query } : {}), waitUntil: "ready" });
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
// The home page may hand off to another page itself (a gate, a login
|
|
423
|
+
// redirect); that is the app's own start, not a failed relaunch.
|
|
424
|
+
if (!/was disposed before runtime became ready/.test(String(error?.message ?? error)))
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Controls that carry file contents: the report names the files
|
|
430
|
+
* (`openapiFiles`, `coversManifestFile`) instead of repeating them.
|
|
431
|
+
*/
|
|
432
|
+
const PAYLOAD_CONTROL_KEYS = ["openapi", "coversManifest"];
|
|
433
|
+
/**
|
|
434
|
+
* The run budget lxdev asked for: a fixed `budgetMs`, or scaled to the
|
|
435
|
+
* planned executions — `max(budgetMinMs, planned × budgetPerSpecMs)`, capped
|
|
436
|
+
* at `budgetMaxMs`. `undefined` when lxdev sent neither (an older lxdev: the
|
|
437
|
+
* host's own deadline is the only budget).
|
|
438
|
+
*/
|
|
439
|
+
export function runBudget(control, planned) {
|
|
440
|
+
const fixed = Number(control.budgetMs);
|
|
441
|
+
if (control.budgetMs !== undefined && Number.isFinite(fixed) && fixed > 0)
|
|
442
|
+
return { ms: fixed, auto: false };
|
|
443
|
+
const perSpec = Number(control.budgetPerSpecMs);
|
|
444
|
+
if (control.budgetPerSpecMs === undefined || !Number.isFinite(perSpec) || perSpec <= 0)
|
|
445
|
+
return undefined;
|
|
446
|
+
const min = Number(control.budgetMinMs ?? 0);
|
|
447
|
+
const max = Number(control.budgetMaxMs ?? Number.POSITIVE_INFINITY);
|
|
448
|
+
const scaled = Math.max(Number.isFinite(min) ? min : 0, planned * perSpec);
|
|
449
|
+
return { ms: Math.min(scaled, Number.isFinite(max) && max > 0 ? max : scaled), auto: true };
|
|
450
|
+
}
|
|
451
|
+
/** Seeded Fisher–Yates (mulberry32), so a printed seed reproduces the order. */
|
|
452
|
+
export function shuffleWithSeed(items, seed) {
|
|
453
|
+
let state = seed >>> 0;
|
|
454
|
+
const next = () => {
|
|
455
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
456
|
+
let t = state;
|
|
457
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
458
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
459
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
460
|
+
};
|
|
461
|
+
const out = [...items];
|
|
462
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
463
|
+
const j = Math.floor(next() * (i + 1));
|
|
464
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
/** `secretArgs` is a JSON list of `--secret-arg` keys. */
|
|
469
|
+
function secretKeys(listed) {
|
|
470
|
+
if (!listed)
|
|
471
|
+
return [];
|
|
472
|
+
let parsed;
|
|
473
|
+
try {
|
|
474
|
+
parsed = JSON.parse(listed);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
throw new Error(`the host's secretArgs control is not a JSON list: ${JSON.stringify(listed)}`);
|
|
478
|
+
}
|
|
479
|
+
if (!Array.isArray(parsed)) {
|
|
480
|
+
throw new Error(`the host's secretArgs control is not a JSON list: ${JSON.stringify(listed)}`);
|
|
481
|
+
}
|
|
482
|
+
return parsed.filter((key) => typeof key === "string");
|
|
483
|
+
}
|
|
484
|
+
/** Run the selected specs. */
|
|
485
|
+
function run() {
|
|
486
|
+
return runSpecs(false);
|
|
487
|
+
}
|
|
488
|
+
/** `lxdev test --list`: the selected specs, in `listed`; nothing runs. */
|
|
489
|
+
function list() {
|
|
490
|
+
return runSpecs(true);
|
|
491
|
+
}
|
|
492
|
+
async function runSpecs(listOnly) {
|
|
136
493
|
var _a;
|
|
137
494
|
warnVersionSkew();
|
|
138
|
-
const
|
|
495
|
+
const rawHost = resolveHost();
|
|
496
|
+
const { args, control } = rawHost;
|
|
497
|
+
// Secret args reach the spec through `t.arg()` but never an event or report.
|
|
498
|
+
const redact = createRedactor(args, secretKeys(control.secretArgs));
|
|
499
|
+
const host = {
|
|
500
|
+
...rawHost,
|
|
501
|
+
args,
|
|
502
|
+
emit: (event) => rawHost.emit(redact.deep(event)),
|
|
503
|
+
};
|
|
139
504
|
const started = Date.now();
|
|
140
505
|
clearInline();
|
|
506
|
+
contractSeq = 0;
|
|
507
|
+
assertAttributed();
|
|
508
|
+
assignFileIndexes();
|
|
141
509
|
const ids = new Map();
|
|
142
510
|
for (const item of specs) {
|
|
143
511
|
const id = resolvedId(item);
|
|
@@ -147,44 +515,105 @@ async function run() {
|
|
|
147
515
|
}
|
|
148
516
|
ids.set(id, item.title);
|
|
149
517
|
}
|
|
150
|
-
const grep =
|
|
518
|
+
const grep = control.grep;
|
|
519
|
+
const recordNetwork = control.recordNetwork === "1";
|
|
520
|
+
if (recordNetwork && !host.networkRecord)
|
|
521
|
+
throw new Error("--record-network needs a host that records network traffic");
|
|
151
522
|
const pattern = grep ? new RegExp(grep) : undefined;
|
|
152
|
-
const forbidOnly =
|
|
523
|
+
const forbidOnly = control.forbidOnly === "1" || control["forbid-only"] === "1";
|
|
153
524
|
const hasOnly = specs.some((item) => item.annotation === "only");
|
|
154
525
|
if (hasOnly && forbidOnly) {
|
|
155
526
|
throw new Error("spec.only is registered; lxdev test --forbid-only refuses to run");
|
|
156
527
|
}
|
|
157
|
-
const
|
|
528
|
+
const tagFilter = control.tags ? parseTagFilter(JSON.parse(control.tags)) : [];
|
|
529
|
+
const manifest = parseManifest(control.coversManifest);
|
|
530
|
+
const openapi = parseOpenApiControl(control.openapi);
|
|
531
|
+
setActiveOpenApi(openapi);
|
|
532
|
+
const ledger = openapi ? new ContractLedger(openapi) : undefined;
|
|
533
|
+
const specFiles = new Set(specs.map((item) => sourceOf(item).file));
|
|
534
|
+
const fileOptions = new Map();
|
|
535
|
+
for (const config of fileConfigs) {
|
|
536
|
+
const file = resolveOwner(config.frames, specFiles).file;
|
|
537
|
+
if (!specFiles.has(file)) {
|
|
538
|
+
await host.emit({ type: "diagnostic", phase: "collect",
|
|
539
|
+
message: `spec.configure() called from ${file} applies to no spec: no spec file is on its call stack. Call it from a spec file's top level.` });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
fileOptions.set(file, mergeFileOptions(fileOptions.get(file), config.options));
|
|
543
|
+
}
|
|
544
|
+
for (const item of specs)
|
|
545
|
+
settle(item, fileOptions.get(sourceOf(item).file));
|
|
546
|
+
const tagsOf = (item) => item.tags;
|
|
547
|
+
const retries = Number(control.retries ?? 0);
|
|
158
548
|
if (!Number.isInteger(retries) || retries < 0 || retries > 10)
|
|
159
549
|
throw new Error("retries must be between 0 and 10");
|
|
160
|
-
const selectedIds =
|
|
161
|
-
const shard =
|
|
550
|
+
const selectedIds = control.ids ? JSON.parse(control.ids) : undefined;
|
|
551
|
+
const shard = control.shard?.split("/").map(Number);
|
|
162
552
|
if (shard && (shard.length !== 2 || shard.some(n => !Number.isInteger(n) || n < 1) || shard[0] > shard[1]))
|
|
163
553
|
throw new Error("shard must be INDEX/TOTAL (1-based)");
|
|
554
|
+
const locations = parseLocations(control.locations);
|
|
164
555
|
const selected = specs.filter((item) => {
|
|
165
556
|
if (hasOnly && item.annotation !== "only")
|
|
166
557
|
return false;
|
|
558
|
+
if (locations && !atLocation(sourceOf(item), locations))
|
|
559
|
+
return false;
|
|
167
560
|
if (selectedIds && !selectedIds.includes(resolvedId(item)))
|
|
168
561
|
return false;
|
|
169
562
|
if (shard && stableHash(resolvedId(item)) % shard[1] !== shard[0] - 1)
|
|
170
563
|
return false;
|
|
171
|
-
if (
|
|
564
|
+
if (control.id && resolvedId(item) !== control.id)
|
|
565
|
+
return false;
|
|
566
|
+
if (tagFilter.length > 0 && !matchesTags(tagsOf(item), tagFilter))
|
|
172
567
|
return false;
|
|
173
568
|
if (!grep)
|
|
174
569
|
return true;
|
|
175
570
|
const id = resolvedId(item);
|
|
176
571
|
return pattern.test(item.title) || pattern.test(id);
|
|
177
572
|
});
|
|
178
|
-
if (
|
|
573
|
+
if (listOnly) {
|
|
574
|
+
return listing(selected.map((item) => ({ id: resolvedId(item), title: item.title, ...sourceOf(item), tags: tagsOf(item) })));
|
|
575
|
+
}
|
|
576
|
+
if (selected.length === 0 && control.passWithNoTests !== "1") {
|
|
179
577
|
throw new Error("No tests matched this selection. Check the entry and filters, or use --pass-with-no-tests.");
|
|
180
578
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
579
|
+
const repeatEach = Number(control.repeatEach ?? 1);
|
|
580
|
+
if (!Number.isInteger(repeatEach) || repeatEach < 1 || repeatEach > 100)
|
|
581
|
+
throw new Error("repeatEach must be between 1 and 100");
|
|
582
|
+
const shuffleSeed = control.shuffle === undefined ? undefined : Number(control.shuffle);
|
|
583
|
+
if (shuffleSeed !== undefined && (!Number.isInteger(shuffleSeed) || shuffleSeed < 0))
|
|
584
|
+
throw new Error("shuffle seed must be a non-negative integer");
|
|
585
|
+
const repeated = selected.flatMap((item) => Array.from({ length: repeatEach }, (_, index) => ({ item, repeat: index + 1 })));
|
|
586
|
+
const plan = shuffleSeed === undefined ? repeated : shuffleWithSeed(repeated, shuffleSeed);
|
|
587
|
+
const budget = runBudget(control, plan.length);
|
|
588
|
+
const fullName = (item, repeat) => (item.id ? `${item.id} | ${item.title}` : item.title) + (repeatEach > 1 ? ` [repeat ${repeat}/${repeatEach}]` : "");
|
|
589
|
+
// `args` is the masked view, so lxdev can write the same one into any
|
|
590
|
+
// report it has to complete itself.
|
|
591
|
+
await host.emit({ type: "run_started", schema_version: 1, total: plan.length, args: redact.args(args),
|
|
592
|
+
cases: plan.map(({ item, repeat }) => ({ id: resolvedId(item), title: item.title, name: item.title,
|
|
593
|
+
full_name: fullName(item, repeat), ...sourceOf(item), ...(repeatEach > 1 ? { repeat } : {}),
|
|
594
|
+
suite: suiteOf(sourceOf(item).file), timeout_ms: item.timeout, covers: item.covers, tags: tagsOf(item) })) });
|
|
595
|
+
if (manifest) {
|
|
596
|
+
const known = new Set(manifest.map((entry) => entry.id));
|
|
597
|
+
const stray = [...new Set(specs.flatMap((item) => item.covers).filter((id) => !known.has(id)))];
|
|
598
|
+
if (stray.length > 0) {
|
|
599
|
+
await host.emit({ type: "diagnostic", phase: "coverage",
|
|
600
|
+
message: `${stray.length} covers id(s) not in the coverage manifest: ${stray.slice(0, 10).join(", ")}${stray.length > 10 ? ", …" : ""}` });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Resolve each hook's spec file once, so a `beforeEach` stays scoped to the
|
|
604
|
+
// file that registered it rather than running for every spec in the run. A
|
|
605
|
+
// hook registered by a shared helper belongs to the spec file that called
|
|
606
|
+
// the helper (the nearest spec file on its registration stack).
|
|
607
|
+
const hookFiles = new Map([...hooks, ...afterHooks, ...resetHooks].map((hook) => [hook, resolveOwner(hook.frames, specFiles).file]));
|
|
608
|
+
for (const [kind, list] of [["beforeEach", hooks], ["afterEach", afterHooks], ["reset", resetHooks]]) {
|
|
609
|
+
for (const hook of list) {
|
|
610
|
+
const file = hookFiles.get(hook);
|
|
611
|
+
if (specFiles.has(file))
|
|
612
|
+
continue;
|
|
613
|
+
await host.emit({ type: "diagnostic", phase: "collect",
|
|
614
|
+
message: `spec.${kind}() registered from ${file} never runs: no spec file is on its call stack. Call it (or the helper that registers it) from a spec file's top level.` });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
188
617
|
if (retries > 0) {
|
|
189
618
|
for (const item of selected.filter(item => !["skip", "fixme"].includes(item.annotation))) {
|
|
190
619
|
if (!resetHooks.some(hook => hookFiles.get(hook) === sourceOf(item).file)) {
|
|
@@ -195,29 +624,60 @@ async function run() {
|
|
|
195
624
|
const subject = await describeSubject();
|
|
196
625
|
const cases = [];
|
|
197
626
|
forceRelaunchNext = false;
|
|
627
|
+
// Set only when the run cannot go on safely: recovering the app after a
|
|
628
|
+
// spec left work pending failed, or a restoreProfile rollback did not run.
|
|
198
629
|
let contaminated = false;
|
|
199
|
-
|
|
630
|
+
let contaminationReason;
|
|
631
|
+
installPendingTracker();
|
|
632
|
+
restoreRawAuthority();
|
|
633
|
+
fenceGlobalAutomation();
|
|
634
|
+
// Late assertions are charged to the spec the run last abandoned, never
|
|
635
|
+
// to the one running.
|
|
636
|
+
let lastAbandoned;
|
|
637
|
+
setStrayAssertionSink(strayAssertionSink(host, () => lastAbandoned));
|
|
638
|
+
const queue = [...plan];
|
|
200
639
|
const attempts = new Map();
|
|
201
|
-
|
|
640
|
+
const executionKey = (id, repeat) => `${id}#${repeat}`;
|
|
641
|
+
let budgetExhausted;
|
|
642
|
+
// Set once, when a spec is about to start on a locked screen: every page is
|
|
643
|
+
// hidden until someone unlocks it, which the run cannot do.
|
|
644
|
+
let screenLocked;
|
|
645
|
+
const executed = () => new Set(cases.map((done) => executionKey(done.id, done.repeat ?? 1))).size;
|
|
646
|
+
for (const planned of queue) {
|
|
647
|
+
const { item, repeat } = planned;
|
|
202
648
|
const id = resolvedId(item);
|
|
649
|
+
const key = executionKey(id, repeat);
|
|
203
650
|
const timeout = item.timeout;
|
|
204
651
|
const source = sourceOf(item);
|
|
652
|
+
// Checked between specs: a spec already running keeps its own timeout.
|
|
653
|
+
if (!budgetExhausted && budget && Date.now() - started >= budget.ms) {
|
|
654
|
+
budgetExhausted = { after: executed() };
|
|
655
|
+
await host.emit({ type: "diagnostic", phase: "budget",
|
|
656
|
+
message: `Run budget of ${Math.round(budget.ms / 1000)}s exhausted after ${budgetExhausted.after}/${plan.length} specs; the rest are reported as not run. Raise --timeout-secs, or split the suite with --shard.` });
|
|
657
|
+
}
|
|
658
|
+
if (!budgetExhausted && !contaminated && !screenLocked && host.screenLocked()) {
|
|
659
|
+
screenLocked = { after: executed() };
|
|
660
|
+
await host.emit({ type: "diagnostic", phase: "screen_locked",
|
|
661
|
+
message: `${capitalize(SCREEN_LOCKED_NOTE)}. Stopped after ${screenLocked.after}/${plan.length} specs; the rest are reported as not run.` });
|
|
662
|
+
}
|
|
205
663
|
const record = {
|
|
206
664
|
id,
|
|
207
665
|
title: item.title,
|
|
208
666
|
name: item.title,
|
|
209
|
-
full_name: item
|
|
667
|
+
full_name: fullName(item, repeat),
|
|
668
|
+
...(repeatEach > 1 ? { repeat } : {}),
|
|
210
669
|
file: source.file,
|
|
211
670
|
line: source.line,
|
|
212
671
|
suite: suiteOf(source.file),
|
|
213
672
|
status: "passed",
|
|
214
673
|
duration_ms: 0,
|
|
215
674
|
covers: [...item.covers],
|
|
675
|
+
tags: tagsOf(item),
|
|
216
676
|
steps: [],
|
|
217
677
|
assertions: [],
|
|
218
678
|
attachments: [],
|
|
219
679
|
timeout_ms: timeout,
|
|
220
|
-
attempt: attempts.get(
|
|
680
|
+
attempt: attempts.get(key)?.length ?? 0,
|
|
221
681
|
reason: item.reason,
|
|
222
682
|
};
|
|
223
683
|
await host.emit({
|
|
@@ -228,26 +688,127 @@ async function run() {
|
|
|
228
688
|
timeout_ms: timeout,
|
|
229
689
|
watchdog_timeout_ms: timeout + (item.timeoutCleanup ?? MAX_DEFER_BUDGET_MS) + FORENSICS_BUDGET_MS + WEDGED_DEFER_BUDGET_MS,
|
|
230
690
|
covers: record.covers,
|
|
691
|
+
tags: record.tags,
|
|
231
692
|
});
|
|
232
693
|
const caseStarted = Date.now();
|
|
233
|
-
|
|
694
|
+
const unmet = item.annotation === "skip" || item.annotation === "fixme"
|
|
695
|
+
? undefined
|
|
696
|
+
: unmetRequirements(item, args, openapi !== undefined);
|
|
697
|
+
if (budgetExhausted || contaminated || screenLocked || unmet !== undefined || item.annotation === "skip" || item.annotation === "fixme") {
|
|
234
698
|
if (contaminated)
|
|
235
|
-
record.reason =
|
|
699
|
+
record.reason = contaminationReason ?? `Not run: an earlier spec left the run unable to continue. Recover: ${RECOVER_COMMAND}`;
|
|
700
|
+
else if (budgetExhausted && budget)
|
|
701
|
+
record.reason = `Not run: the run budget of ${Math.round(budget.ms / 1000)}s was exhausted after ${budgetExhausted.after}/${plan.length} specs.`;
|
|
702
|
+
else if (screenLocked)
|
|
703
|
+
record.reason = `Not run: ${SCREEN_LOCKED_NOTE}.`;
|
|
704
|
+
else if (unmet !== undefined)
|
|
705
|
+
record.reason = unmet;
|
|
236
706
|
record.status = "skipped";
|
|
237
707
|
record.duration_ms = Date.now() - caseStarted;
|
|
238
708
|
cases.push(record);
|
|
239
709
|
await finishCase(host, record);
|
|
240
710
|
continue;
|
|
241
711
|
}
|
|
242
|
-
|
|
712
|
+
// One spec that leaves the app under test closed (a crash, a dev reload,
|
|
713
|
+
// Logic torn down after a hung eval) must not fail every spec after it.
|
|
714
|
+
const reopenStarted = Date.now();
|
|
715
|
+
const reopened = await reopenAppUnderTest(item.app ?? subject?.appid);
|
|
716
|
+
if (reopened) {
|
|
717
|
+
const previous = cases[cases.length - 1];
|
|
718
|
+
await host.emit({ type: "diagnostic", phase: reopened.error ? "recovery_failed" : "recovery",
|
|
719
|
+
message: `The app under test (${reopened.appId}) was not running before "${item.title}"` +
|
|
720
|
+
(previous ? `; it stopped during or after "${previous.title}" (${previous.status})` : "") +
|
|
721
|
+
`. Reopened it on its home page${reopened.error ? `, which failed: ${reopened.error}` : ""}.` });
|
|
722
|
+
}
|
|
723
|
+
// What the spec installs from here on — routes, a mock scenario, test
|
|
724
|
+
// clocks — belongs to this attempt; the host removes it when the
|
|
725
|
+
// attempt ends, before the next spec starts.
|
|
726
|
+
const attempt = host.beginAttempt?.();
|
|
727
|
+
const fixture = new LiveFixture(`${encodeURIComponent(id)}${repeatEach > 1 ? `/repeat-${repeat}` : ""}/attempt-${record.attempt}`,
|
|
728
|
+
// The app under test by id: "current" follows whichever app a spec
|
|
729
|
+
// brought to the front.
|
|
730
|
+
pinApp(item.app ?? subject?.appid), host, args, automationRoot(), timeout, redact, item.app ?? subject?.appid, caseStarted);
|
|
731
|
+
if (reopened) {
|
|
732
|
+
fixture.steps.push({ name: "app.reopen", kind: "action", path: "app.reopen",
|
|
733
|
+
detail: `${reopened.appId} was not running`, status: reopened.error ? "failed" : "passed",
|
|
734
|
+
duration_ms: Date.now() - reopenStarted, steps: [], attachments: [], assertions: [] });
|
|
735
|
+
}
|
|
736
|
+
// Toasts are recorded and modals / action sheets answered for this spec
|
|
737
|
+
// only; the attempt's end removes the watch whatever happens below.
|
|
738
|
+
let dialogSetupError;
|
|
739
|
+
try {
|
|
740
|
+
await fixture.watchPrimaryDialogs();
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
dialogSetupError = error ?? new Error("dialog watch failed");
|
|
744
|
+
}
|
|
243
745
|
let status = "passed";
|
|
244
746
|
let error;
|
|
245
747
|
let phase = "body";
|
|
246
|
-
|
|
748
|
+
let recordingNetwork = false;
|
|
749
|
+
let recordingSetupError;
|
|
750
|
+
if (recordNetwork) {
|
|
751
|
+
try {
|
|
752
|
+
await startNetworkRecording(host);
|
|
753
|
+
recordingNetwork = true;
|
|
754
|
+
}
|
|
755
|
+
catch (startError) {
|
|
756
|
+
recordingSetupError = startError;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const shouldRelaunch = item.start !== undefined || item.restoreProfile || forceRelaunchNext;
|
|
247
760
|
forceRelaunchNext = false;
|
|
761
|
+
// `restoreProfile`: snapshot the isolated profile now and roll back to it
|
|
762
|
+
// after the spec. Registered as the first cleanup, so it runs last.
|
|
763
|
+
let profileRestored = false;
|
|
764
|
+
// Timers and fetches from here on belong to this spec, so one it leaves
|
|
765
|
+
// pending can be named.
|
|
766
|
+
const grant = setPendingOwner(record.full_name);
|
|
767
|
+
const stuckWork = () => [
|
|
768
|
+
...fixture.pendingCalls().map((work) => ({ ...work, owner: record.full_name })),
|
|
769
|
+
...pendingOf(record.full_name),
|
|
770
|
+
];
|
|
771
|
+
// Work this spec left pending when the run stopped waiting for it.
|
|
772
|
+
// `spec`: spec code (its body, cleanup or calls it did not await) may
|
|
773
|
+
// still run; the runner's own evidence capture is not spec code.
|
|
774
|
+
let stuck;
|
|
775
|
+
// The run gives up on spec code: from here its raw drivers refuse, and
|
|
776
|
+
// what it may still do is decided below.
|
|
777
|
+
const abandon = (what, work) => {
|
|
778
|
+
stuck ?? (stuck = { what, work, spec: true });
|
|
779
|
+
revokeGrant(grant, `"${record.full_name}" was abandoned (${what})`);
|
|
780
|
+
};
|
|
248
781
|
const bodyPromise = (async () => {
|
|
782
|
+
// Each spec starts with fresh mock handler state, before its hooks.
|
|
783
|
+
phase = "beforeEach";
|
|
784
|
+
if (dialogSetupError !== undefined)
|
|
785
|
+
throw dialogSetupError;
|
|
786
|
+
if (recordingSetupError !== undefined)
|
|
787
|
+
throw recordingSetupError;
|
|
788
|
+
if (ledger) {
|
|
789
|
+
await within(Promise.resolve(fixture.raw.network.captureResponses()), CONTRACT_CALL_MS, "captureResponses timed out");
|
|
790
|
+
}
|
|
791
|
+
const mockNote = await resetMocks(fixture.raw, mockResetReasons).catch((error) => `mock handler state was not reset: ${String(error?.message ?? error)}`);
|
|
792
|
+
if (mockNote)
|
|
793
|
+
await host.emit({ type: "diagnostic", phase: "mock", message: mockNote });
|
|
794
|
+
phase = "body";
|
|
795
|
+
if (item.restoreProfile) {
|
|
796
|
+
phase = "beforeEach";
|
|
797
|
+
const checkpoint = await fixture.app.profile.checkpoint();
|
|
798
|
+
fixture.defer(async () => {
|
|
799
|
+
try {
|
|
800
|
+
await fixture.app.profile.restore(checkpoint, item.restoreKeep ? { keep: item.restoreKeep } : undefined);
|
|
801
|
+
profileRestored = true;
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
// A failed rollback must not leave its copy on disk for the rest of the run.
|
|
805
|
+
await Promise.resolve(fixture.app.profile.drop(checkpoint)).catch(() => { });
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
phase = "body";
|
|
809
|
+
}
|
|
249
810
|
if (shouldRelaunch)
|
|
250
|
-
await relaunchHome(fixture.raw);
|
|
811
|
+
await relaunchHome(fixture.raw, item.start);
|
|
251
812
|
phase = "beforeEach";
|
|
252
813
|
for (const hook of resetHooks) {
|
|
253
814
|
if (hookFiles.get(hook) === source.file)
|
|
@@ -267,15 +828,32 @@ async function run() {
|
|
|
267
828
|
void bodyResult.then(() => { bodySettled = true; });
|
|
268
829
|
let timerHandle;
|
|
269
830
|
const timer = new Promise((resolve) => {
|
|
270
|
-
timerHandle =
|
|
831
|
+
timerHandle = runnerSetTimeout(() => {
|
|
271
832
|
timedOut = true;
|
|
272
833
|
fixture.abort(timeoutError);
|
|
273
834
|
resolve("timeout");
|
|
274
835
|
}, timeout);
|
|
275
836
|
});
|
|
837
|
+
// A modal or action sheet with no answer queued fails the spec at once,
|
|
838
|
+
// instead of leaving it waiting on a dialog nobody answers.
|
|
839
|
+
const unanswered = fixture.dialogFailure.then((message) => ({ unanswered: message }));
|
|
276
840
|
try {
|
|
277
|
-
const winner = await Promise.race([bodyResult, timer]);
|
|
278
|
-
if (winner === "
|
|
841
|
+
const winner = await Promise.race([bodyResult, timer, unanswered]);
|
|
842
|
+
if (typeof winner === "object" && "unanswered" in winner) {
|
|
843
|
+
const dialogError = new Error(`Unanswered dialog: ${winner.unanswered}`);
|
|
844
|
+
status = "failed";
|
|
845
|
+
error = dialogError;
|
|
846
|
+
fixture.abort(dialogError, "failed");
|
|
847
|
+
try {
|
|
848
|
+
await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after an unanswered dialog");
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
const work = stuckWork();
|
|
852
|
+
abandon("its body after an unanswered dialog", work);
|
|
853
|
+
dialogError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else if (winner === "timeout") {
|
|
279
857
|
status = "timeout";
|
|
280
858
|
error = timeoutError;
|
|
281
859
|
phase = "timeout";
|
|
@@ -284,28 +862,56 @@ async function run() {
|
|
|
284
862
|
await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after timeout");
|
|
285
863
|
}
|
|
286
864
|
catch {
|
|
287
|
-
|
|
865
|
+
const work = stuckWork();
|
|
866
|
+
abandon("its timed-out body", work);
|
|
867
|
+
timeoutError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
|
|
288
868
|
}
|
|
289
869
|
}
|
|
290
870
|
else if (!winner.ok) {
|
|
871
|
+
// The first error wins; calls the body left running are stopped
|
|
872
|
+
// before cleanup all the same.
|
|
873
|
+
if (fixture.unsettledCalls().length > 0 && !timedOut && !fixture.aborted &&
|
|
874
|
+
!await fixture.stopUnsettled(winner.err instanceof Error ? winner.err : new Error(String(winner.err)), WEDGED_DEFER_BUDGET_MS)) {
|
|
875
|
+
abandon("fixture calls its body did not await", stuckWork());
|
|
876
|
+
}
|
|
291
877
|
if (timedOut || fixture.aborted) {
|
|
292
878
|
status = "timeout";
|
|
293
879
|
error = timeoutError;
|
|
294
880
|
phase = "timeout";
|
|
295
881
|
forceRelaunchNext = true;
|
|
296
882
|
}
|
|
883
|
+
else if (winner.err instanceof SkipSignal) {
|
|
884
|
+
status = "skipped";
|
|
885
|
+
}
|
|
297
886
|
else {
|
|
298
887
|
status = "failed";
|
|
299
888
|
error = winner.err;
|
|
300
889
|
fixture.failurePhase = phase;
|
|
301
890
|
}
|
|
302
891
|
}
|
|
892
|
+
else if (fixture.skipReason !== undefined) {
|
|
893
|
+
// The body caught the signal; the skip was still requested.
|
|
894
|
+
status = "skipped";
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
// A call the body did not await would turn a failure into a pass.
|
|
898
|
+
const unsettled = fixture.unsettledCalls();
|
|
899
|
+
if (unsettled.length > 0) {
|
|
900
|
+
status = "failed";
|
|
901
|
+
error = new Error(unawaitedMessage(item.title, unsettled));
|
|
902
|
+
fixture.failurePhase = "body";
|
|
903
|
+
if (!await fixture.stopUnsettled(error, WEDGED_DEFER_BUDGET_MS)) {
|
|
904
|
+
abandon("fixture calls its body did not await", stuckWork());
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
303
908
|
}
|
|
304
909
|
finally {
|
|
305
910
|
if (timerHandle !== undefined)
|
|
306
|
-
|
|
911
|
+
runnerClearTimeout(timerHandle);
|
|
307
912
|
}
|
|
308
913
|
let evidenceCollected = false;
|
|
914
|
+
let failurePage;
|
|
309
915
|
const collectEvidence = async () => {
|
|
310
916
|
if (evidenceCollected || !item.forensics)
|
|
311
917
|
return;
|
|
@@ -313,17 +919,29 @@ async function run() {
|
|
|
313
919
|
try {
|
|
314
920
|
// The whole point of the timeout path is that a wedged app does not
|
|
315
921
|
// stall the run, and these calls bypass `guard` on the raw driver.
|
|
316
|
-
await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
|
|
922
|
+
failurePage = await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
|
|
317
923
|
}
|
|
318
924
|
catch (forensicsError) {
|
|
319
925
|
// Evidence failures must never replace the product failure.
|
|
320
|
-
if (forensicsError instanceof TimeoutError)
|
|
321
|
-
|
|
926
|
+
if (forensicsError instanceof TimeoutError) {
|
|
927
|
+
stuck ?? (stuck = { what: "failure evidence capture", work: [{ kind: "action", detail: "screenshot, page and log capture", owner: record.full_name }], spec: false });
|
|
928
|
+
}
|
|
322
929
|
await host.emit({ type: "diagnostic", phase: "forensics", message: String(forensicsError) });
|
|
323
930
|
}
|
|
324
931
|
};
|
|
325
|
-
if (status !== "passed")
|
|
932
|
+
if (status !== "passed" && status !== "skipped")
|
|
326
933
|
await collectEvidence();
|
|
934
|
+
// Before the defers remove it: the scenario and what reached it.
|
|
935
|
+
const scenarioEvidence = status !== "passed" && status !== "skipped"
|
|
936
|
+
? await fixture.scenarioScope.report(SCENARIO_EVIDENCE_MS)
|
|
937
|
+
: undefined;
|
|
938
|
+
const selectedScenario = fixture.scenarioScope.current;
|
|
939
|
+
if (selectedScenario)
|
|
940
|
+
record.scenario = {
|
|
941
|
+
label: selectedScenario.label,
|
|
942
|
+
name: selectedScenario.raw.name,
|
|
943
|
+
variant: selectedScenario.raw.variant,
|
|
944
|
+
};
|
|
327
945
|
phase = "defer";
|
|
328
946
|
const deferErrors = [];
|
|
329
947
|
const cleanupBudget = item.timeoutCleanup ?? (status === "timeout" ? WEDGED_DEFER_BUDGET_MS : MAX_DEFER_BUDGET_MS);
|
|
@@ -349,7 +967,7 @@ async function run() {
|
|
|
349
967
|
deferErrors.push(err);
|
|
350
968
|
}
|
|
351
969
|
if (!settled) {
|
|
352
|
-
|
|
970
|
+
abandon("its cleanup (t.defer / afterEach)", stuckWork());
|
|
353
971
|
break;
|
|
354
972
|
}
|
|
355
973
|
}
|
|
@@ -358,12 +976,170 @@ async function run() {
|
|
|
358
976
|
deferErrors.push(new Error("Cleanup skipped because the timed-out body is still running"));
|
|
359
977
|
}
|
|
360
978
|
fixture.endCleanup();
|
|
979
|
+
// A spec that returned with fetches or raw driver calls still in flight
|
|
980
|
+
// could act on the next spec when they settle: give them a moment, and
|
|
981
|
+
// stop the run if they do not settle.
|
|
982
|
+
if (!stuck) {
|
|
983
|
+
const open = await settleCallsOf(record.full_name, WEDGED_DEFER_BUDGET_MS);
|
|
984
|
+
if (open.length > 0) {
|
|
985
|
+
abandon("calls it did not await", open);
|
|
986
|
+
if (status === "passed" || status === "skipped") {
|
|
987
|
+
status = "failed";
|
|
988
|
+
error = new Error(`${JSON.stringify(item.title)} ended while ${describePending(open)} ` +
|
|
989
|
+
`${open.length === 1 ? "was" : "were"} still running, and ${open.length === 1 ? "it" : "they"} did not settle ` +
|
|
990
|
+
`within ${WEDGED_DEFER_BUDGET_MS}ms (was it awaited?)`);
|
|
991
|
+
fixture.failurePhase = "body";
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// Its timers must not fire into the next spec.
|
|
996
|
+
let cancelled = cancelTimersOf(record.full_name);
|
|
997
|
+
if (cancelled > 0 && !stuck) {
|
|
998
|
+
await host.emit({ type: "diagnostic", phase: "cleanup",
|
|
999
|
+
message: `"${record.full_name}" ended with ${cancelled} test-context ${cancelled === 1 ? "timer" : "timers"} pending; ` +
|
|
1000
|
+
`cancelled so ${cancelled === 1 ? "it never fires" : "they never fire"} into a later spec.` });
|
|
1001
|
+
}
|
|
1002
|
+
// An answer the spec queued that no dialog used: the dialog it expected
|
|
1003
|
+
// never came.
|
|
1004
|
+
let dialogCleanupError;
|
|
1005
|
+
let unusedAnswers;
|
|
1006
|
+
if (!stuck) {
|
|
1007
|
+
try {
|
|
1008
|
+
const dialogs = await within(fixture.endDialogWatches(), WEDGED_DEFER_BUDGET_MS, "dialog unwatch timed out");
|
|
1009
|
+
unusedAnswers = dialogs.unused.length > 0 ? dialogs.unused.join("; ") : undefined;
|
|
1010
|
+
dialogCleanupError = dialogs.failures.length > 0 ? `dialog observation could not finish: ${dialogs.failures.join("; ")}` : undefined;
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
dialogCleanupError = `dialog observation could not finish: ${String(error)}`;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (unusedAnswers !== undefined) {
|
|
1017
|
+
if (status === "passed") {
|
|
1018
|
+
status = "failed";
|
|
1019
|
+
error = new Error(`${JSON.stringify(item.title)}: ${unusedAnswers}.`);
|
|
1020
|
+
fixture.failurePhase = "body";
|
|
1021
|
+
}
|
|
1022
|
+
else if (error instanceof Error) {
|
|
1023
|
+
error.message += `\nAlso: ${unusedAnswers}.`;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Framework cleanup is not the spec's: it runs whether or not the body
|
|
1027
|
+
// settled, and a resource it cannot remove fails the spec and stops the
|
|
1028
|
+
// run — a later spec would run against it.
|
|
1029
|
+
const reclaimFailures = dialogCleanupError ? [dialogCleanupError] : [];
|
|
1030
|
+
if (recordingNetwork) {
|
|
1031
|
+
const recordingError = await saveNetworkRecording(host, fixture, record.title);
|
|
1032
|
+
if (recordingError !== undefined)
|
|
1033
|
+
reclaimFailures.push(`the network recording could not be completed: ${recordingError}`);
|
|
1034
|
+
}
|
|
1035
|
+
else if (recordingSetupError !== undefined) {
|
|
1036
|
+
// Start may have reached the host before its acknowledgement failed.
|
|
1037
|
+
try {
|
|
1038
|
+
host.networkRecord?.("stop", record.title);
|
|
1039
|
+
}
|
|
1040
|
+
catch (stopError) {
|
|
1041
|
+
reclaimFailures.push(`the unconfirmed network recording could not be stopped: ${errorMessage(stopError)}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
reclaimFailures.push(...await within(fixture.reclaim(), RECLAIM_BUDGET_MS, `removing the spec's routes, mock scenario and test clocks did not finish within ${RECLAIM_BUDGET_MS}ms`));
|
|
1046
|
+
}
|
|
1047
|
+
catch (reclaimError) {
|
|
1048
|
+
reclaimFailures.push(errorMessage(reclaimError));
|
|
1049
|
+
}
|
|
1050
|
+
// Closing the attempt removes what it installed and refuses installs
|
|
1051
|
+
// until the next opens, whether or not its code still runs: its drivers
|
|
1052
|
+
// were revoked when it was abandoned.
|
|
1053
|
+
if (attempt !== undefined) {
|
|
1054
|
+
try {
|
|
1055
|
+
const swept = await within(host.endAttempt(attempt), RECLAIM_BUDGET_MS, `the host did not finish removing what the spec installed within ${RECLAIM_BUDGET_MS}ms`);
|
|
1056
|
+
if (swept.droppedTimers > 0)
|
|
1057
|
+
forceRelaunchNext = true;
|
|
1058
|
+
// Left for the host to sweep: installed where the fixture could not
|
|
1059
|
+
// track it (the raw driver), so the spec did not clean it up.
|
|
1060
|
+
if (swept.clocks > 0) {
|
|
1061
|
+
await host.emit({ type: "diagnostic", phase: "clock",
|
|
1062
|
+
message: `"${record.full_name}" left ${swept.clocks} test clock${swept.clocks === 1 ? "" : "s"} installed outside t.app.clock; ` +
|
|
1063
|
+
`the spec's end removed ${swept.clocks === 1 ? "it" : "them"}` +
|
|
1064
|
+
(swept.droppedTimers > 0 ? ` and dropped ${swept.droppedTimers} pending test timer${swept.droppedTimers === 1 ? "" : "s"}; they never fired.` : ".") });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (sweepError) {
|
|
1068
|
+
reclaimFailures.push(`the host could not remove what the spec installed: ${errorMessage(sweepError)}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (reclaimFailures.length > 0) {
|
|
1072
|
+
deferErrors.push(...reclaimFailures.map((failure) => new Error(failure)));
|
|
1073
|
+
if (!contaminated) {
|
|
1074
|
+
contaminated = true;
|
|
1075
|
+
const what = `"${record.full_name}" left test resources the run could not remove (${reclaimFailures.join("; ")}); ` +
|
|
1076
|
+
"a later spec would run against them.";
|
|
1077
|
+
contaminationReason = `Not run: ${what} Recover: ${RECOVER_COMMAND}`;
|
|
1078
|
+
await host.emit({ type: "diagnostic", phase: "run_stopped",
|
|
1079
|
+
message: `${what} The remaining specs are not run. Recover: ${RECOVER_COMMAND}` });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// Test timers dropped with the spec's clock leave the app's polling loops
|
|
1083
|
+
// and debounces dead; the next spec starts from a relaunched home page.
|
|
1084
|
+
if (fixture.clockScope.dropped > 0)
|
|
1085
|
+
forceRelaunchNext = true;
|
|
1086
|
+
// An abandoned spec stops the run below with its own reason.
|
|
1087
|
+
if (item.restoreProfile && !profileRestored && !contaminated && !stuck) {
|
|
1088
|
+
// The next spec would start on this spec's data.
|
|
1089
|
+
contaminated = true;
|
|
1090
|
+
const what = `"${record.full_name}" could not roll the app's data back (restoreProfile); a later spec would run on its data.`;
|
|
1091
|
+
contaminationReason = `Not run: ${what} Restart the run.`;
|
|
1092
|
+
await host.emit({ type: "diagnostic", phase: "run_stopped",
|
|
1093
|
+
message: `${what} The remaining specs are not run.` });
|
|
1094
|
+
}
|
|
1095
|
+
// Routed responses that break the contract fail the spec like a body
|
|
1096
|
+
// failure would, so `spec.fail({ expected: { code: 'E_OPENAPI_CONTRACT' } })`
|
|
1097
|
+
// can declare one.
|
|
1098
|
+
if (ledger) {
|
|
1099
|
+
let checked = undefined;
|
|
1100
|
+
try {
|
|
1101
|
+
checked = await checkContract(ledger, fixture, id, host);
|
|
1102
|
+
}
|
|
1103
|
+
catch (captureError) {
|
|
1104
|
+
const message = `OpenAPI response capture failed: ${captureError instanceof Error ? captureError.message : String(captureError)}`;
|
|
1105
|
+
if (error instanceof Error)
|
|
1106
|
+
error.message += `\n${message}`;
|
|
1107
|
+
else
|
|
1108
|
+
error = new Error(message);
|
|
1109
|
+
if (status === "passed")
|
|
1110
|
+
status = "failed";
|
|
1111
|
+
fixture.failurePhase = "capture";
|
|
1112
|
+
}
|
|
1113
|
+
if (checked) {
|
|
1114
|
+
record.contract = checked;
|
|
1115
|
+
if (checked.violations.length > 0 && status !== "skipped") {
|
|
1116
|
+
const contractError = new ContractError(checked.violations);
|
|
1117
|
+
if (status === "failed" || status === "timeout") {
|
|
1118
|
+
if (error instanceof Error)
|
|
1119
|
+
error.message += `\n${contractError.message}`;
|
|
1120
|
+
else
|
|
1121
|
+
error = contractError;
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
status = "failed";
|
|
1125
|
+
error = contractError;
|
|
1126
|
+
fixture.failurePhase = "contract";
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
361
1131
|
if (item.annotation === "fail") {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1132
|
+
// Without `expected`, the declared failure is whatever the body does to
|
|
1133
|
+
// fail: a product rejection counts as much as an assertion. With it,
|
|
1134
|
+
// only a matching failure is the known one; anything else — a mistyped
|
|
1135
|
+
// selector, a dead fixture server — is a real failure. Setup failures,
|
|
1136
|
+
// skips and timeouts keep their own verdicts.
|
|
1137
|
+
if (status === "failed" && (fixture.failurePhase === "body" || fixture.failurePhase === "contract")) {
|
|
1138
|
+
const mismatch = item.expected ? expectedMismatch(error, item.expected) : undefined;
|
|
1139
|
+
if (mismatch === undefined)
|
|
1140
|
+
status = "xfail";
|
|
1141
|
+
else
|
|
1142
|
+
error = mismatch;
|
|
367
1143
|
}
|
|
368
1144
|
else if (status === "passed") {
|
|
369
1145
|
status = "xpass";
|
|
@@ -389,64 +1165,257 @@ async function run() {
|
|
|
389
1165
|
error = new Error(`t.defer failed:\n${cleanupText}`);
|
|
390
1166
|
}
|
|
391
1167
|
}
|
|
392
|
-
|
|
1168
|
+
// Overlapping steps are a misuse even when the body swallowed the error,
|
|
1169
|
+
// and never the failure `spec.fail` declared.
|
|
1170
|
+
if (fixture.stepConflict) {
|
|
1171
|
+
if (status === "passed" || status === "xfail" || status === "xpass") {
|
|
1172
|
+
status = "failed";
|
|
1173
|
+
error = fixture.stepConflict;
|
|
1174
|
+
fixture.failurePhase = "body";
|
|
1175
|
+
}
|
|
1176
|
+
else if (status === "failed" && error instanceof Error && error !== fixture.stepConflict) {
|
|
1177
|
+
error.message += `\nAlso: ${fixture.stepConflict.message}`;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// A timed-out remote task may still run in the app: the case fails even
|
|
1181
|
+
// if it caught the timeout, and the next spec starts from a relaunch.
|
|
1182
|
+
if (fixture.remoteTimeoutKind) {
|
|
1183
|
+
const reason = `a timed-out ${fixture.remoteTimeoutKind} may still run in the app`;
|
|
1184
|
+
// An xfail may have declared this very timeout; the relaunch still applies.
|
|
1185
|
+
if ((status === "passed" || status === "xpass") && !fixture.stepConflict) {
|
|
1186
|
+
status = "failed";
|
|
1187
|
+
error = new Error(`${reason}; the next spec starts from a relaunched app`);
|
|
1188
|
+
fixture.failurePhase = "body";
|
|
1189
|
+
}
|
|
1190
|
+
forceRelaunchNext = true;
|
|
1191
|
+
await host.emit({ type: "diagnostic", phase: "recovery",
|
|
1192
|
+
message: `"${record.full_name}": ${reason}; the next spec starts from a relaunched app.` });
|
|
1193
|
+
}
|
|
1194
|
+
else if (fixture.detachedInFlight()) {
|
|
1195
|
+
forceRelaunchNext = true;
|
|
1196
|
+
await host.emit({ type: "diagnostic", phase: "recovery",
|
|
1197
|
+
message: `"${record.full_name}": a read t.waitFor gave up on is still running in the app; the next spec starts from a relaunched app.` });
|
|
1198
|
+
}
|
|
1199
|
+
if (status !== "passed" && status !== "skipped")
|
|
393
1200
|
await collectEvidence();
|
|
1201
|
+
if (status === "skipped")
|
|
1202
|
+
record.reason = fixture.skipReason ?? record.reason;
|
|
394
1203
|
fixture.close();
|
|
395
1204
|
record.status = status;
|
|
396
1205
|
record.duration_ms = Date.now() - caseStarted;
|
|
397
|
-
|
|
398
|
-
|
|
1206
|
+
// Copies: code of this spec that is still running cannot change a
|
|
1207
|
+
// finished record.
|
|
1208
|
+
record.steps = fixture.steps.map(copyStepRecord);
|
|
1209
|
+
record.assertions = fixture.assertions.map((entry) => ({ ...entry }));
|
|
1210
|
+
if (fixture.traceTruncated)
|
|
1211
|
+
record.trace_truncated = true;
|
|
1212
|
+
// Counted over the host's whole call log; only the latest are listed.
|
|
1213
|
+
const attemptNetwork = networkCalls(host, caseStarted, SUMMARIZED_NETWORK_CALLS);
|
|
1214
|
+
const recentNetwork = attemptNetwork.slice(-REPORTED_NETWORK_CALLS);
|
|
1215
|
+
if (recentNetwork.length > 0) {
|
|
1216
|
+
record.network_calls = recentNetwork.map((call) => {
|
|
1217
|
+
const at_ms = Math.max(0, call.time - caseStarted);
|
|
1218
|
+
const observed_during = observedStepAt(fixture.steps, at_ms);
|
|
1219
|
+
return { ...call, at_ms,
|
|
1220
|
+
...(observed_during ? { observed_during } : {}),
|
|
1221
|
+
...(call.route ? { route: { ...call.route } } : {}) };
|
|
1222
|
+
});
|
|
1223
|
+
record.network_summary = {
|
|
1224
|
+
observed: attemptNetwork.length,
|
|
1225
|
+
routed: attemptNetwork.filter((call) => call.source === "route").length,
|
|
1226
|
+
real: attemptNetwork.filter((call) => call.source === "network").length,
|
|
1227
|
+
limit_reached: attemptNetwork.length > REPORTED_NETWORK_CALLS,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
399
1230
|
if (fixture.observed.size > 0)
|
|
400
1231
|
record.observed = [...fixture.observed].sort();
|
|
401
|
-
record.attachments = fixture.attachments;
|
|
402
|
-
|
|
1232
|
+
record.attachments = [...fixture.attachments];
|
|
1233
|
+
setExpectScope();
|
|
403
1234
|
if (error)
|
|
404
1235
|
record.error = toReportError(error, fixture.currentStepPath());
|
|
405
1236
|
if (record.error) {
|
|
406
1237
|
record.error.phase = status === "timeout" ? "timeout" : fixture.failurePhase ?? phase;
|
|
407
1238
|
// JSC tail calls can omit the authored assertion frame.
|
|
408
1239
|
(_a = record.error).location ?? (_a.location = `${source.file}:${source.line}:1`);
|
|
1240
|
+
// Only the action that produced this very error explains it: an action
|
|
1241
|
+
// the spec expected to fail (`t.reject`) must not be blamed later.
|
|
1242
|
+
if (fixture.failedAction && fixture.failedAction.error === error) {
|
|
1243
|
+
record.error.failedAction = fixture.failedAction.action;
|
|
1244
|
+
}
|
|
1245
|
+
// A spec that never reached a page is not "on" the app's current one.
|
|
1246
|
+
const current = fixture.usedPage || item.start ? failurePage : undefined;
|
|
1247
|
+
const page = current ? uiFailureNote(current, record.error, error, fixture) : pageFromErrorData(record.error.data);
|
|
1248
|
+
if (page)
|
|
1249
|
+
record.error.page = page;
|
|
1250
|
+
const calls = withFunctionCalls(networkCalls(host, caseStarted), scenarioEvidence?.functionCalls ?? []);
|
|
1251
|
+
if (calls.length > 0)
|
|
1252
|
+
record.error.network = calls;
|
|
1253
|
+
if (scenarioEvidence)
|
|
1254
|
+
record.error.scenario = scenarioEvidence.scenario;
|
|
409
1255
|
}
|
|
410
|
-
const history = attempts.get(
|
|
1256
|
+
const history = attempts.get(key) ?? [];
|
|
411
1257
|
history.push(record);
|
|
412
|
-
attempts.set(
|
|
1258
|
+
attempts.set(key, history);
|
|
413
1259
|
const finished = { ...record, attempts: history.length > 1 ? history.map(attempt => ({ ...attempt })) : undefined,
|
|
414
1260
|
flaky: record.status === "passed" && history.length > 1 };
|
|
415
|
-
const previous = cases.findIndex(item => item.id === id);
|
|
1261
|
+
const previous = cases.findIndex(item => item.id === id && (item.repeat ?? 1) === repeat);
|
|
416
1262
|
if (previous >= 0)
|
|
417
1263
|
cases[previous] = finished;
|
|
418
1264
|
else
|
|
419
1265
|
cases.push(finished);
|
|
420
1266
|
await finishCase(host, finished);
|
|
1267
|
+
if (stuck) {
|
|
1268
|
+
// Shared-context user continuations cannot be proven dead. Reclaim and
|
|
1269
|
+
// recover for inspection, then stop; only runner-owned evidence work can
|
|
1270
|
+
// time out without abandoning user code.
|
|
1271
|
+
if (stuck.spec)
|
|
1272
|
+
lastAbandoned = record.full_name;
|
|
1273
|
+
const inFlight = stuck.spec ? await abandonedWorkInFlight(record.full_name, fixture) : [];
|
|
1274
|
+
cancelled += cancelTimersOf(record.full_name);
|
|
1275
|
+
const failure = await recoverAppUnderTest(item.app ?? subject?.appid);
|
|
1276
|
+
cancelled += cancelTimersOf(record.full_name);
|
|
1277
|
+
const unfenced = stuck.spec && grant ? [...grant.unfenced] : [];
|
|
1278
|
+
const summary = `"${record.full_name}" left ${stuck.what} pending: ${describePending(stuck.work)}` +
|
|
1279
|
+
(cancelled > 0 ? `; cancelled its ${cancelled} pending ${cancelled === 1 ? "timer" : "timers"}` : "");
|
|
1280
|
+
const unsafe = [
|
|
1281
|
+
...(stuck.spec ? ["abandoned code can resume through an untracked promise in this shared JS context"] : []),
|
|
1282
|
+
...(inFlight.length > 0
|
|
1283
|
+
? [`${describePending(inFlight)} ${inFlight.length === 1 ? "is" : "are"} still running after ${WEDGED_DEFER_BUDGET_MS}ms and would resume its code during a later spec`]
|
|
1284
|
+
: []),
|
|
1285
|
+
...(unfenced.length > 0
|
|
1286
|
+
? [`it holds host drivers the run cannot revoke (${unfenced.map((name) => `rawAutomation().${name}`).join(", ")})`]
|
|
1287
|
+
: []),
|
|
1288
|
+
];
|
|
1289
|
+
if (stuck.spec) {
|
|
1290
|
+
// Isolation cannot be guaranteed: stop, and revoke all spec code.
|
|
1291
|
+
const revokeError = await revokeSpecAuthority(host, undefined, `${summary}; the run stopped`);
|
|
1292
|
+
const recovered = failure === undefined
|
|
1293
|
+
? "Relaunched the app under test on its home page for inspection"
|
|
1294
|
+
: `Recovering the app failed: ${failure}`;
|
|
1295
|
+
const reason = `${summary}. The run stopped: ${unsafe.join("; ")}. ${recovered}` +
|
|
1296
|
+
(revokeError ? `; revoking its automation access failed: ${revokeError}` : "") +
|
|
1297
|
+
". Make the spec settle (await its work), or give it a longer timeout.";
|
|
1298
|
+
const recover = failure !== undefined || revokeError ? ` Recover: ${RECOVER_COMMAND}` : "";
|
|
1299
|
+
if (!contaminated) {
|
|
1300
|
+
contaminated = true;
|
|
1301
|
+
contaminationReason = `Not run: ${reason}${recover}`;
|
|
1302
|
+
}
|
|
1303
|
+
forceRelaunchNext = false;
|
|
1304
|
+
await host.emit({ type: "diagnostic", phase: failure === undefined ? "run_stopped" : "recovery_failed",
|
|
1305
|
+
message: `${reason} The remaining specs are not run.${recover}` });
|
|
1306
|
+
}
|
|
1307
|
+
else if (failure === undefined) {
|
|
1308
|
+
forceRelaunchNext = false;
|
|
1309
|
+
await host.emit({ type: "diagnostic", phase: "recovery",
|
|
1310
|
+
message: `${summary}. Relaunched the app under test on its home page; the run continues.` });
|
|
1311
|
+
}
|
|
1312
|
+
else if (!contaminated) {
|
|
1313
|
+
contaminated = true;
|
|
1314
|
+
contaminationReason = `Not run: ${summary}, and recovering the app failed: ${failure}. Recover: ${RECOVER_COMMAND}`;
|
|
1315
|
+
if (stuck.spec)
|
|
1316
|
+
await revokeSpecAuthority(host, undefined, `${summary}; the run stopped`);
|
|
1317
|
+
await host.emit({ type: "diagnostic", phase: "recovery_failed",
|
|
1318
|
+
message: `${summary}, and recovering the app failed: ${failure}. The remaining specs are not run. Recover: ${RECOVER_COMMAND}` });
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
setPendingOwner(undefined);
|
|
421
1322
|
if (!contaminated && (status === "failed" || status === "timeout") && history.length <= retries) {
|
|
422
|
-
queue.splice(queue.indexOf(
|
|
1323
|
+
queue.splice(queue.indexOf(planned) + 1, 0, { item: { ...item }, repeat });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
uninstallPendingTracker();
|
|
1327
|
+
unfenceGlobalAutomation();
|
|
1328
|
+
setStrayAssertionSink();
|
|
1329
|
+
// The run ends as each spec starts: with the app under test running, so
|
|
1330
|
+
// what comes next (a rerun, `lxdev lxapp`, a developer) does not meet a
|
|
1331
|
+
// closed app. Not after pending async work, which reopening could race.
|
|
1332
|
+
if (!contaminated && cases.length > 0) {
|
|
1333
|
+
const reopened = await reopenAppUnderTest(subject?.appid);
|
|
1334
|
+
if (reopened) {
|
|
1335
|
+
await host.emit({ type: "diagnostic", phase: reopened.error ? "recovery_failed" : "recovery",
|
|
1336
|
+
message: `The app under test (${reopened.appId}) was not running at the end of the run` +
|
|
1337
|
+
(reopened.error ? `, and reopening it failed: ${reopened.error}` : "; reopened it on its home page.") });
|
|
423
1338
|
}
|
|
424
1339
|
}
|
|
425
1340
|
const counts = countStatuses(cases);
|
|
426
1341
|
const duration_ms = Date.now() - started;
|
|
1342
|
+
const reportedControl = { ...control };
|
|
1343
|
+
for (const key of PAYLOAD_CONTROL_KEYS)
|
|
1344
|
+
delete reportedControl[key];
|
|
427
1345
|
const json = {
|
|
428
1346
|
schema_version: 1,
|
|
429
1347
|
framework: { name: PACKAGE_NAME, version: VERSION },
|
|
430
1348
|
meta: {
|
|
431
1349
|
started_at: new Date(started).toISOString(),
|
|
432
1350
|
duration_ms,
|
|
433
|
-
args:
|
|
434
|
-
|
|
435
|
-
|
|
1351
|
+
args: redact.args(args),
|
|
1352
|
+
run: redact.deep(reportedControl),
|
|
1353
|
+
platform: control.platform,
|
|
1354
|
+
framework: args.framework,
|
|
436
1355
|
subject,
|
|
437
1356
|
surface_coverage: trackSurface,
|
|
1357
|
+
...(budget ? { budget: { ms: budget.ms, auto: budget.auto, planned: plan.length,
|
|
1358
|
+
...(budgetExhausted ? { exhausted_after: budgetExhausted.after } : {}) } } : {}),
|
|
1359
|
+
...(shuffleSeed !== undefined ? { shuffle_seed: shuffleSeed } : {}),
|
|
1360
|
+
...(repeatEach > 1 ? { repeat_each: repeatEach } : {}),
|
|
438
1361
|
},
|
|
439
|
-
partial: contaminated,
|
|
440
|
-
filtered: Boolean(grep ||
|
|
1362
|
+
partial: contaminated || budgetExhausted !== undefined || screenLocked !== undefined,
|
|
1363
|
+
filtered: Boolean(grep || control.id || control.ids || shard || locations || tagFilter.length > 0) || hasOnly,
|
|
441
1364
|
duration_ms,
|
|
442
1365
|
...counts,
|
|
443
|
-
cases,
|
|
1366
|
+
cases: redact.deep(cases),
|
|
444
1367
|
};
|
|
1368
|
+
json.failures = failureRecords(json.cases);
|
|
1369
|
+
const tagRows = tagSummary(json.cases);
|
|
1370
|
+
if (tagRows.length > 0)
|
|
1371
|
+
json.tag_summary = tagRows;
|
|
1372
|
+
if (manifest) {
|
|
1373
|
+
json.coverage = coverageSummary(manifest, json.cases, specs.map((item) => ({ id: resolvedId(item), title: item.title, covers: item.covers })));
|
|
1374
|
+
}
|
|
1375
|
+
if (ledger)
|
|
1376
|
+
json.openapi = redact.deep(ledger.summary);
|
|
445
1377
|
await attachText(host, "report.json", JSON.stringify(json, null, 2), "application/json");
|
|
446
1378
|
await attachText(host, "report.html", renderHtml(json), "text/html; charset=utf-8");
|
|
447
1379
|
await attachText(host, "junit.xml", renderJUnit(json), "application/xml; charset=utf-8");
|
|
448
1380
|
return json;
|
|
449
1381
|
}
|
|
1382
|
+
/** Calls a body returned without awaiting; more than this many are counted. */
|
|
1383
|
+
const LISTED_UNAWAITED = 5;
|
|
1384
|
+
function unawaitedMessage(title, calls) {
|
|
1385
|
+
const count = calls.length === 1 ? "1 fixture call was" : `${calls.length} fixture calls were`;
|
|
1386
|
+
const lines = calls.slice(0, LISTED_UNAWAITED).map((entry) => ` ${entry.call} at ${entry.at} (started ${entry.ageMs}ms ago)`);
|
|
1387
|
+
if (calls.length > LISTED_UNAWAITED)
|
|
1388
|
+
lines.push(` … and ${calls.length - LISTED_UNAWAITED} more`);
|
|
1389
|
+
return [`${JSON.stringify(title)} returned while ${count} still running (was it awaited?):`, ...lines].join("\n");
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* `undefined` when `error` is the failure `spec.fail` declared; otherwise the
|
|
1393
|
+
* error to report, saying what was expected instead.
|
|
1394
|
+
*/
|
|
1395
|
+
function expectedMismatch(error, expected) {
|
|
1396
|
+
const record = (error && typeof error === "object" ? error : {});
|
|
1397
|
+
const message = error instanceof Error ? error.message : typeof record.message === "string" ? record.message : String(error);
|
|
1398
|
+
const problems = [];
|
|
1399
|
+
if (expected.code !== undefined && !matchesErrorCode(error, expected.code)) {
|
|
1400
|
+
problems.push(`code ${formatValue(expected.code)}, got ${formatValue(record.code)}`);
|
|
1401
|
+
}
|
|
1402
|
+
if (typeof expected.message === "string" && !message.includes(expected.message)) {
|
|
1403
|
+
problems.push(`a message containing ${formatValue(expected.message)}`);
|
|
1404
|
+
}
|
|
1405
|
+
if (expected.message instanceof RegExp) {
|
|
1406
|
+
expected.message.lastIndex = 0;
|
|
1407
|
+
if (!expected.message.test(message))
|
|
1408
|
+
problems.push(`a message matching ${String(expected.message)}`);
|
|
1409
|
+
}
|
|
1410
|
+
if (problems.length === 0)
|
|
1411
|
+
return undefined;
|
|
1412
|
+
const header = `spec.fail expected a failure with ${problems.join(" and ")}; the body failed differently:`;
|
|
1413
|
+
if (error instanceof Error) {
|
|
1414
|
+
error.message = `${header}\n${error.message}`;
|
|
1415
|
+
return error;
|
|
1416
|
+
}
|
|
1417
|
+
return new Error(`${header}\n${message}`);
|
|
1418
|
+
}
|
|
450
1419
|
async function finishCase(host, record) {
|
|
451
1420
|
await host.emit({
|
|
452
1421
|
type: "case_finished",
|
|
@@ -459,31 +1428,250 @@ async function finishCase(host, record) {
|
|
|
459
1428
|
record,
|
|
460
1429
|
});
|
|
461
1430
|
}
|
|
462
|
-
/**
|
|
1431
|
+
/** How long a run start waits for the host to name its current lxapp. */
|
|
1432
|
+
const SUBJECT_WAIT_MS = 15000;
|
|
1433
|
+
/**
|
|
1434
|
+
* The app under test: the host's current lxapp. A host whose runtime just
|
|
1435
|
+
* (re)connected may not have shown its app yet, so this waits for it, and
|
|
1436
|
+
* opens an app the host names but has not opened. Best-effort: a run
|
|
1437
|
+
* against an unreachable app still reports its cases.
|
|
1438
|
+
*/
|
|
463
1439
|
async function describeSubject() {
|
|
464
|
-
|
|
465
|
-
|
|
1440
|
+
const deadline = Date.now() + SUBJECT_WAIT_MS;
|
|
1441
|
+
let opened;
|
|
1442
|
+
for (;;) {
|
|
1443
|
+
try {
|
|
1444
|
+
const info = (await pinApp().info());
|
|
1445
|
+
return {
|
|
1446
|
+
appid: asText(info.appId),
|
|
1447
|
+
app_name: asText(info.appName),
|
|
1448
|
+
version: asText(info.version),
|
|
1449
|
+
release_type: asText(info.releaseType),
|
|
1450
|
+
pages: typeof info.pagesCount === "number" ? info.pagesCount : undefined,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
const message = errorMessage(error);
|
|
1455
|
+
const inactive = /lxapp is not active: (\S+)/.exec(message)?.[1];
|
|
1456
|
+
if (inactive !== undefined && opened !== inactive) {
|
|
1457
|
+
opened = inactive;
|
|
1458
|
+
await reopenAppUnderTest(inactive);
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (!(inactive !== undefined || /no current lxapp/.test(message)) || Date.now() >= deadline)
|
|
1462
|
+
return undefined;
|
|
1463
|
+
await new Promise((resolve) => { runnerSetTimeout(resolve, 200); });
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
function capitalize(text) {
|
|
1468
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1469
|
+
}
|
|
1470
|
+
function asText(value) {
|
|
1471
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1472
|
+
}
|
|
1473
|
+
/** The current page as a driver error's `data.current` names it. */
|
|
1474
|
+
function pageFromErrorData(data) {
|
|
1475
|
+
const current = data && typeof data === "object" ? data.current : undefined;
|
|
1476
|
+
return current && typeof current === "object" ? toFailurePage(current) : undefined;
|
|
1477
|
+
}
|
|
1478
|
+
function toFailurePage(value) {
|
|
1479
|
+
if (!value || typeof value !== "object")
|
|
1480
|
+
return undefined;
|
|
1481
|
+
const page = value;
|
|
1482
|
+
const name = typeof page.name === "string" ? page.name : null;
|
|
1483
|
+
const instanceId = typeof page.instanceId === "string" ? page.instanceId : null;
|
|
1484
|
+
return name === null && instanceId === null ? undefined : { name, instanceId };
|
|
1485
|
+
}
|
|
1486
|
+
/** `failures[]`: every failing case, flat, with what failed and where. */
|
|
1487
|
+
export function failureRecords(cases) {
|
|
1488
|
+
return cases
|
|
1489
|
+
.filter((item) => item.status === "failed" || item.status === "timeout" || item.status === "xpass")
|
|
1490
|
+
.map((item) => {
|
|
1491
|
+
const error = item.error;
|
|
1492
|
+
const screenshot = item.attachments.find((attachment) => attachment.name === "failure.png")?.path;
|
|
466
1493
|
return {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1494
|
+
id: item.id,
|
|
1495
|
+
title: item.title,
|
|
1496
|
+
file: item.file,
|
|
1497
|
+
line: item.line,
|
|
1498
|
+
phase: error?.phase,
|
|
1499
|
+
code: error?.code,
|
|
1500
|
+
message: error?.message ?? item.status,
|
|
1501
|
+
failedAction: error?.failedAction,
|
|
1502
|
+
page: error?.page,
|
|
1503
|
+
screenshot,
|
|
1504
|
+
...(error?.network && error.network.length > 0 ? { network: error.network } : {}),
|
|
1505
|
+
...(error?.scenario ? { scenario: error.scenario } : {}),
|
|
472
1506
|
};
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
/** The per-spec attachment `lxdev test --record-network` collects. */
|
|
1510
|
+
const RECORDED_SCENARIO = "network.scenario.json";
|
|
1511
|
+
/** Logic network calls a failed spec reports: the last 20 since it started. */
|
|
1512
|
+
const REPORTED_NETWORK_CALLS = 20;
|
|
1513
|
+
/** The host's call log bound: a summary counts every call it still holds. */
|
|
1514
|
+
const SUMMARIZED_NETWORK_CALLS = 200;
|
|
1515
|
+
function networkCalls(host, since, limit = REPORTED_NETWORK_CALLS) {
|
|
1516
|
+
if (!host.networkLog)
|
|
1517
|
+
return [];
|
|
1518
|
+
try {
|
|
1519
|
+
const calls = host.networkLog(since, limit);
|
|
1520
|
+
return Array.isArray(calls) ? calls.slice(-limit) : [];
|
|
473
1521
|
}
|
|
474
1522
|
catch {
|
|
1523
|
+
// The call log is evidence; it never replaces the failure.
|
|
1524
|
+
return [];
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/** Only a temporal association: app work can start during a step for unrelated reasons. */
|
|
1528
|
+
function observedStepAt(steps, atMs) {
|
|
1529
|
+
for (const step of steps) {
|
|
1530
|
+
if (step.kind === "action" || step.at_ms === undefined || step.duration_unknown)
|
|
1531
|
+
continue;
|
|
1532
|
+
if (atMs < step.at_ms || atMs > step.at_ms + step.duration_ms)
|
|
1533
|
+
continue;
|
|
1534
|
+
return observedStepAt(step.steps, atMs) ?? step.path;
|
|
1535
|
+
}
|
|
1536
|
+
return undefined;
|
|
1537
|
+
}
|
|
1538
|
+
/** How long a failed spec waits for its scenario's calls. */
|
|
1539
|
+
const SCENARIO_EVIDENCE_MS = 2000;
|
|
1540
|
+
/** The Logic calls and the scenario's Function calls, newest 20 by time. */
|
|
1541
|
+
function withFunctionCalls(calls, functions) {
|
|
1542
|
+
if (functions.length === 0)
|
|
1543
|
+
return calls;
|
|
1544
|
+
const converted = functions.map((call) => ({
|
|
1545
|
+
time: call.time,
|
|
1546
|
+
kind: "function",
|
|
1547
|
+
method: "",
|
|
1548
|
+
url: "",
|
|
1549
|
+
function: call.function,
|
|
1550
|
+
outcome: call.outcome,
|
|
1551
|
+
status: null,
|
|
1552
|
+
durationMs: null,
|
|
1553
|
+
source: call.rule === null ? "network" : "route",
|
|
1554
|
+
answeredBy: call.answeredBy,
|
|
1555
|
+
...(call.noMatch ? { noMatch: call.noMatch } : {}),
|
|
1556
|
+
}));
|
|
1557
|
+
return [...calls, ...converted].sort((a, b) => a.time - b.time).slice(-REPORTED_NETWORK_CALLS);
|
|
1558
|
+
}
|
|
1559
|
+
/** `--record-network`: capture this spec's real Logic traffic. */
|
|
1560
|
+
async function startNetworkRecording(host) {
|
|
1561
|
+
try {
|
|
1562
|
+
host.networkRecord("start");
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
const message = `network recording could not start: ${errorMessage(error)}`;
|
|
1566
|
+
await host.emit({ type: "diagnostic", phase: "record-network", message });
|
|
1567
|
+
throw new Error(message);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** Stop this spec's recording and attach it; resolves why it failed, if it did. */
|
|
1571
|
+
async function saveNetworkRecording(host, fixture, title) {
|
|
1572
|
+
let scenario;
|
|
1573
|
+
try {
|
|
1574
|
+
scenario = host.networkRecord?.("stop", title);
|
|
1575
|
+
}
|
|
1576
|
+
catch (error) {
|
|
1577
|
+
return errorMessage(error);
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
if (scenario && typeof scenario === "object")
|
|
1581
|
+
await fixture.attachRaw(RECORDED_SCENARIO, scenario);
|
|
1582
|
+
}
|
|
1583
|
+
catch (error) {
|
|
1584
|
+
return `network recording could not be attached: ${errorMessage(error)}`;
|
|
1585
|
+
}
|
|
1586
|
+
return undefined;
|
|
1587
|
+
}
|
|
1588
|
+
/** How long removing one spec's routes, scenario and clocks may take. */
|
|
1589
|
+
const RECLAIM_BUDGET_MS = 10000;
|
|
1590
|
+
function errorMessage(error) {
|
|
1591
|
+
return String(error?.message ?? error);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Stop spec code of this run from driving the app: `rawAutomation()` and
|
|
1595
|
+
* every proxy it handed out refuse from now on, and the host refuses the
|
|
1596
|
+
* context's driver calls and removes what the open attempt installed.
|
|
1597
|
+
* Resolves why the host could not, if it could not.
|
|
1598
|
+
*/
|
|
1599
|
+
async function revokeSpecAuthority(host, attempt, reason) {
|
|
1600
|
+
revokeRawAuthority(reason);
|
|
1601
|
+
try {
|
|
1602
|
+
if (host.revoke)
|
|
1603
|
+
await within(host.revoke(reason), RECLAIM_BUDGET_MS, `the host did not finish revoking within ${RECLAIM_BUDGET_MS}ms`);
|
|
1604
|
+
else if (attempt !== undefined && host.endAttempt)
|
|
1605
|
+
await within(host.endAttempt(attempt), RECLAIM_BUDGET_MS, `the host did not finish removing what the spec installed within ${RECLAIM_BUDGET_MS}ms`);
|
|
475
1606
|
return undefined;
|
|
476
1607
|
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
return errorMessage(error);
|
|
1610
|
+
}
|
|
477
1611
|
}
|
|
478
|
-
|
|
479
|
-
|
|
1612
|
+
/**
|
|
1613
|
+
* Work an abandoned spec dispatched that is still running after a grace
|
|
1614
|
+
* period: fetches and raw driver calls (and what their continuations start
|
|
1615
|
+
* meanwhile), and fixture calls the abort could not stop. Empty when all
|
|
1616
|
+
* settled.
|
|
1617
|
+
*/
|
|
1618
|
+
async function abandonedWorkInFlight(name, fixture) {
|
|
1619
|
+
const deadline = Date.now() + WEDGED_DEFER_BUDGET_MS;
|
|
1620
|
+
const open = await settleCallsOf(name, WEDGED_DEFER_BUDGET_MS);
|
|
1621
|
+
let calls = fixture.pendingCalls();
|
|
1622
|
+
while (calls.length > 0 && Date.now() < deadline) {
|
|
1623
|
+
await new Promise((resolve) => { runnerSetTimeout(resolve, 50); });
|
|
1624
|
+
calls = fixture.pendingCalls();
|
|
1625
|
+
}
|
|
1626
|
+
return [...open, ...calls.map((work) => ({ ...work, owner: name }))];
|
|
1627
|
+
}
|
|
1628
|
+
/** Assertions noted while no spec ran, beyond which they are only counted. */
|
|
1629
|
+
const LISTED_STRAY_ASSERTIONS = 5;
|
|
1630
|
+
/** A diagnostic for each assertion that ran while no spec was running. */
|
|
1631
|
+
function strayAssertionSink(host, abandoned) {
|
|
1632
|
+
let seen = 0;
|
|
1633
|
+
return (entry) => {
|
|
1634
|
+
seen += 1;
|
|
1635
|
+
if (seen > LISTED_STRAY_ASSERTIONS)
|
|
1636
|
+
return;
|
|
1637
|
+
const from = abandoned();
|
|
1638
|
+
const message = `An assertion ran while no spec was running, so no case records it: ` +
|
|
1639
|
+
`expect(received).${entry.matcher} ${entry.passed ? "passed" : "failed"} (expected ${entry.expected}, received ${entry.actual}). ` +
|
|
1640
|
+
(from !== undefined
|
|
1641
|
+
? `It comes from ${JSON.stringify(from)}, which the run abandoned; its verdict stands.`
|
|
1642
|
+
: "Code of a spec the run stopped waiting for is still running.") +
|
|
1643
|
+
(seen === LISTED_STRAY_ASSERTIONS ? " Further ones are not listed." : "");
|
|
1644
|
+
void host.emit({ type: "diagnostic", phase: "late_assertion", message, ...(from !== undefined ? { case: from } : {}) }).catch(() => { });
|
|
1645
|
+
};
|
|
480
1646
|
}
|
|
1647
|
+
/** Locator actions and one-shot reads, as the trace names them: `page.<verb> [#instance] selector`. */
|
|
1648
|
+
const LOCATOR_ACTION = /^page\.(?:click|fill|type|press|waitFor|count|isVisible|textContent|inputValue|getAttribute)(?: #(\S+))?/;
|
|
1649
|
+
/**
|
|
1650
|
+
* Whether the page looked hidden explains a failure only when a locator
|
|
1651
|
+
* failed; and when the locator's page was not the current one, that is the
|
|
1652
|
+
* explanation instead.
|
|
1653
|
+
*/
|
|
1654
|
+
function uiFailureNote(page, report, error, fixture) {
|
|
1655
|
+
const { hidden, ...rest } = page;
|
|
1656
|
+
const asserted = error && typeof error === "object" ? fixture.locatorFailures.get(error) : undefined;
|
|
1657
|
+
const acted = report.failedAction ? LOCATOR_ACTION.exec(report.failedAction) : null;
|
|
1658
|
+
if (report.phase === "contract" || (asserted === undefined && !acted))
|
|
1659
|
+
return rest;
|
|
1660
|
+
// `#foo` may be a CSS id selector on the current page, not a bound instance.
|
|
1661
|
+
const actedOn = acted?.[1] !== undefined && fixture.boundPages.has(acted[1]) ? acted[1] : null;
|
|
1662
|
+
const target = asserted !== undefined ? asserted : actedOn;
|
|
1663
|
+
if (target !== null && page.instanceId && target !== page.instanceId) {
|
|
1664
|
+
return { ...rest, hidden: `the locator's page #${target} was not the current page` };
|
|
1665
|
+
}
|
|
1666
|
+
return hidden ? { ...rest, hidden } : rest;
|
|
1667
|
+
}
|
|
1668
|
+
/** Attach failure evidence; resolves to the page that was current, if known. */
|
|
481
1669
|
async function captureForensics(fixture) {
|
|
482
1670
|
try {
|
|
483
1671
|
const shot = await fixture.raw.page.screenshot();
|
|
484
1672
|
const payload = encodeScreenshot(shot);
|
|
485
1673
|
if (payload) {
|
|
486
|
-
await fixture.attachRaw("failure.png", payload);
|
|
1674
|
+
await fixture.attachRaw("failure.png", payload, "failure_evidence");
|
|
487
1675
|
}
|
|
488
1676
|
}
|
|
489
1677
|
catch {
|
|
@@ -503,16 +1691,29 @@ async function captureForensics(fixture) {
|
|
|
503
1691
|
catch {
|
|
504
1692
|
info = undefined;
|
|
505
1693
|
}
|
|
1694
|
+
let visibility;
|
|
1695
|
+
try {
|
|
1696
|
+
visibility = pageVisibility(await fixture.raw.page.eval({
|
|
1697
|
+
script: VISIBILITY_PROBE_SCRIPT, timeoutMs: VISIBILITY_PROBE_BUDGET_MS
|
|
1698
|
+
}));
|
|
1699
|
+
}
|
|
1700
|
+
catch {
|
|
1701
|
+
visibility = undefined;
|
|
1702
|
+
}
|
|
506
1703
|
const forensics = {
|
|
507
1704
|
route,
|
|
508
1705
|
info,
|
|
509
1706
|
step: fixture.currentStepPath() ?? null,
|
|
1707
|
+
visibility: visibility ?? null,
|
|
510
1708
|
};
|
|
511
|
-
await fixture.attachRaw("forensics.json", forensics);
|
|
1709
|
+
await fixture.attachRaw("forensics.json", forensics, "failure_evidence");
|
|
512
1710
|
const logs = await fixtureHostLogs();
|
|
513
1711
|
if (logs !== undefined) {
|
|
514
|
-
await fixture.attachRaw("logs.txt", logs);
|
|
1712
|
+
await fixture.attachRaw("logs.txt", logs, "failure_evidence");
|
|
515
1713
|
}
|
|
1714
|
+
const page = toFailurePage(route);
|
|
1715
|
+
const hidden = hiddenCause(visibility?.note);
|
|
1716
|
+
return page && hidden ? { ...page, hidden } : page;
|
|
516
1717
|
}
|
|
517
1718
|
async function fixtureHostLogs() {
|
|
518
1719
|
const host = resolveHost();
|
|
@@ -528,17 +1729,23 @@ function encodeScreenshot(shot) {
|
|
|
528
1729
|
}
|
|
529
1730
|
function reset() {
|
|
530
1731
|
specs.length = 0;
|
|
1732
|
+
fileConfigs.length = 0;
|
|
1733
|
+
setActiveOpenApi(undefined);
|
|
531
1734
|
hooks.length = 0;
|
|
532
1735
|
afterHooks.length = 0;
|
|
533
1736
|
resetHooks.length = 0;
|
|
534
1737
|
trackSurface = false;
|
|
535
|
-
fileCounts.clear();
|
|
536
1738
|
forceRelaunchNext = false;
|
|
1739
|
+
uninstallPendingTracker();
|
|
1740
|
+
unfenceGlobalAutomation();
|
|
1741
|
+
restoreRawAuthority();
|
|
537
1742
|
clearInline();
|
|
538
|
-
|
|
1743
|
+
setExpectScope();
|
|
1744
|
+
setStrayAssertionSink();
|
|
539
1745
|
}
|
|
540
1746
|
const controller = {
|
|
541
1747
|
run,
|
|
1748
|
+
list,
|
|
542
1749
|
version: VERSION,
|
|
543
1750
|
reset,
|
|
544
1751
|
};
|
|
@@ -550,18 +1757,89 @@ if (!globalThis.__LINGXIA_TEST__) {
|
|
|
550
1757
|
writable: false,
|
|
551
1758
|
});
|
|
552
1759
|
}
|
|
553
|
-
export { spec, expect, run, reset, resolvedId, trackPublicSurface };
|
|
1760
|
+
export { spec, expect, run, list, reset, resolvedId, trackPublicSurface };
|
|
1761
|
+
/** Bound on the capture driver calls a contract check makes. */
|
|
1762
|
+
const CONTRACT_CALL_MS = 5000;
|
|
1763
|
+
/** Newest captured response already checked, per run. */
|
|
1764
|
+
let contractSeq = 0;
|
|
1765
|
+
/**
|
|
1766
|
+
* Check the responses captured since the last spec: routed mismatches come
|
|
1767
|
+
* back as violations, the real server's as warnings (also a diagnostic).
|
|
1768
|
+
*/
|
|
1769
|
+
async function checkContract(ledger, fixture, id, host) {
|
|
1770
|
+
let records;
|
|
1771
|
+
try {
|
|
1772
|
+
records = await within(Promise.resolve(fixture.raw.network.responses({ since: contractSeq, waitMs: 2500 })), CONTRACT_CALL_MS, "reading captured responses timed out");
|
|
1773
|
+
}
|
|
1774
|
+
catch (readError) {
|
|
1775
|
+
await host.emit({ type: "diagnostic", phase: "contract",
|
|
1776
|
+
message: `${id}: captured responses could not be read: ${readError instanceof Error ? readError.message : String(readError)}` });
|
|
1777
|
+
throw readError;
|
|
1778
|
+
}
|
|
1779
|
+
for (const record of records)
|
|
1780
|
+
contractSeq = Math.max(contractSeq, record.seq);
|
|
1781
|
+
if (records.length === 0)
|
|
1782
|
+
return undefined;
|
|
1783
|
+
const result = ledger.checkSpec(id, records);
|
|
1784
|
+
for (const warning of result.warnings.slice(0, 3)) {
|
|
1785
|
+
const first = warning.issues[0];
|
|
1786
|
+
await host.emit({ type: "diagnostic", phase: "contract",
|
|
1787
|
+
message: `${id}: ${warning.operation} → ${warning.status} from the server does not match ${warning.schema}` +
|
|
1788
|
+
(first ? ` (at ${first.path || "/"}: ${first.message})` : "") });
|
|
1789
|
+
}
|
|
1790
|
+
return result;
|
|
1791
|
+
}
|
|
554
1792
|
async function within(task, ms, message) {
|
|
555
1793
|
let handle;
|
|
556
1794
|
try {
|
|
557
1795
|
return await Promise.race([task, new Promise((_, reject) => {
|
|
558
|
-
handle =
|
|
1796
|
+
handle = runnerSetTimeout(() => reject(new TimeoutError(message)), ms);
|
|
559
1797
|
})]);
|
|
560
1798
|
}
|
|
561
1799
|
finally {
|
|
562
1800
|
if (handle !== undefined)
|
|
563
|
-
|
|
1801
|
+
runnerClearTimeout(handle);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
function parseLocations(raw) {
|
|
1805
|
+
if (raw === undefined)
|
|
1806
|
+
return undefined;
|
|
1807
|
+
const parsed = JSON.parse(raw);
|
|
1808
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1809
|
+
throw new Error(`the host's locations control is not an object: ${JSON.stringify(raw)}`);
|
|
1810
|
+
}
|
|
1811
|
+
const out = new Map();
|
|
1812
|
+
for (const [file, ranges] of Object.entries(parsed)) {
|
|
1813
|
+
if (ranges !== null && !(Array.isArray(ranges) && ranges.every((r) => Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n))))) {
|
|
1814
|
+
throw new Error(`the host's locations control has a bad range for ${file}`);
|
|
1815
|
+
}
|
|
1816
|
+
out.set(normalizeFile(file), ranges);
|
|
564
1817
|
}
|
|
1818
|
+
return out;
|
|
1819
|
+
}
|
|
1820
|
+
function normalizeFile(file) {
|
|
1821
|
+
return file.replace(/\\/g, "/");
|
|
1822
|
+
}
|
|
1823
|
+
function atLocation(source, locations) {
|
|
1824
|
+
const file = normalizeFile(source.file);
|
|
1825
|
+
if (!locations.has(file))
|
|
1826
|
+
return false;
|
|
1827
|
+
const ranges = locations.get(file);
|
|
1828
|
+
return ranges === null || ranges.some(([from, to]) => source.line >= from && source.line <= to);
|
|
1829
|
+
}
|
|
1830
|
+
/** `lxdev test --list`: the selection, with nothing run. */
|
|
1831
|
+
function listing(listed) {
|
|
1832
|
+
return {
|
|
1833
|
+
schema_version: 1,
|
|
1834
|
+
framework: { name: PACKAGE_NAME, version: VERSION },
|
|
1835
|
+
meta: { started_at: new Date().toISOString(), duration_ms: 0, args: {} },
|
|
1836
|
+
partial: false,
|
|
1837
|
+
filtered: true,
|
|
1838
|
+
total: 0, passed: 0, failed: 0, skipped: 0, xfail: 0, xpass: 0, timeout: 0,
|
|
1839
|
+
duration_ms: 0,
|
|
1840
|
+
cases: [],
|
|
1841
|
+
listed,
|
|
1842
|
+
};
|
|
565
1843
|
}
|
|
566
1844
|
function stableHash(value) {
|
|
567
1845
|
let hash = 2166136261;
|