@lingxia/test 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clock.d.ts +30 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +99 -0
- package/dist/clock.js.map +1 -0
- package/dist/coverage.d.ts +25 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +92 -0
- package/dist/coverage.js.map +1 -0
- package/dist/deadline.d.ts +82 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +184 -0
- package/dist/deadline.js.map +1 -0
- package/dist/dialogs.d.ts +28 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.js +74 -0
- package/dist/dialogs.js.map +1 -0
- package/dist/equal.d.ts +28 -0
- package/dist/equal.d.ts.map +1 -0
- package/dist/equal.js +149 -0
- package/dist/equal.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/expect.d.ts +35 -7
- package/dist/expect.d.ts.map +1 -1
- package/dist/expect.js +216 -68
- package/dist/expect.js.map +1 -1
- package/dist/fixture.d.ts +205 -40
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +1178 -156
- package/dist/fixture.js.map +1 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +78 -0
- package/dist/format.js.map +1 -1
- package/dist/host-types.d.ts +65 -0
- package/dist/host-types.d.ts.map +1 -0
- package/dist/host-types.js +2 -0
- package/dist/host-types.js.map +1 -0
- package/dist/host.d.ts +20 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +34 -2
- package/dist/host.js.map +1 -1
- package/dist/ids.d.ts +16 -0
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +38 -3
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/json-result.d.ts +3 -0
- package/dist/json-result.d.ts.map +1 -0
- package/dist/json-result.js +62 -0
- package/dist/json-result.js.map +1 -0
- package/dist/junit.d.ts +1 -1
- package/dist/junit.d.ts.map +1 -1
- package/dist/junit.js +40 -5
- package/dist/junit.js.map +1 -1
- package/dist/locator.d.ts +116 -7
- package/dist/locator.d.ts.map +1 -1
- package/dist/locator.js +444 -58
- package/dist/locator.js.map +1 -1
- package/dist/mock.d.ts +43 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +162 -0
- package/dist/mock.js.map +1 -0
- package/dist/network.d.ts +68 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +287 -0
- package/dist/network.js.map +1 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +414 -0
- package/dist/openapi.js.map +1 -0
- package/dist/pending.d.ts +84 -0
- package/dist/pending.d.ts.map +1 -0
- package/dist/pending.js +311 -0
- package/dist/pending.js.map +1 -0
- package/dist/redact.d.ts +41 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +168 -0
- package/dist/redact.js.map +1 -0
- package/dist/remote.d.ts +32 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +117 -0
- package/dist/remote.js.map +1 -0
- package/dist/report-extras.d.ts +16 -0
- package/dist/report-extras.d.ts.map +1 -0
- package/dist/report-extras.js +125 -0
- package/dist/report-extras.js.map +1 -0
- package/dist/report-types.d.ts +428 -0
- package/dist/report-types.d.ts.map +1 -0
- package/dist/report-types.js +6 -0
- package/dist/report-types.js.map +1 -0
- package/dist/report.d.ts +15 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +196 -19
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts +13 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +11 -0
- package/dist/runner.js.map +1 -0
- package/dist/runtime.d.ts +55 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1500 -139
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +29 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +463 -0
- package/dist/schema.js.map +1 -0
- package/dist/spec-api.d.ts +10 -2
- package/dist/spec-api.d.ts.map +1 -1
- package/dist/tags.d.ts +26 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +78 -0
- package/dist/tags.js.map +1 -0
- package/dist/types.d.ts +863 -168
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -5
- package/schemas/lxdev.schema.json +50 -0
- package/schemas/mock-config.schema.json +21 -0
- package/schemas/scenario.schema.json +180 -0
package/dist/runtime.js
CHANGED
|
@@ -1,13 +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
|
-
const
|
|
28
|
+
const afterHooks = [];
|
|
29
|
+
const resetHooks = [];
|
|
30
|
+
/** Why a companion does not reset its mock handlers, said once per run. */
|
|
31
|
+
const mockResetReasons = new Set();
|
|
32
|
+
const fileConfigs = [];
|
|
11
33
|
let forceRelaunchNext = false;
|
|
12
34
|
let trackSurface = false;
|
|
13
35
|
/**
|
|
@@ -28,32 +50,127 @@ function parseArgs(optionsOrBody, maybeBody) {
|
|
|
28
50
|
}
|
|
29
51
|
return { options: optionsOrBody, body: maybeBody };
|
|
30
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
|
+
}
|
|
31
135
|
function register(annotation, title, optionsOrBody, maybeBody) {
|
|
32
136
|
if (typeof title !== "string" || title.length === 0) {
|
|
33
137
|
throw new TypeError("spec() requires a non-empty title");
|
|
34
138
|
}
|
|
35
139
|
const { options, body } = parseArgs(optionsOrBody, maybeBody);
|
|
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
|
+
}
|
|
149
|
+
}
|
|
150
|
+
validateOptions(options, `spec ${JSON.stringify(title)}`);
|
|
36
151
|
// The bundle map is installed after the modules run, so keep the raw frames;
|
|
37
152
|
// the authored file is only knowable once the run starts. `frames[0]` is a
|
|
38
153
|
// frame inside this package, identical for every caller, so it can order
|
|
39
154
|
// registrations but must never stand in for identity.
|
|
40
155
|
const frames = captureFrames();
|
|
41
|
-
const
|
|
42
|
-
fileCounts.set(String(indexInFile), indexInFile);
|
|
43
|
-
specs.push({
|
|
156
|
+
const item = {
|
|
44
157
|
title,
|
|
45
158
|
id: options.id,
|
|
46
|
-
covers: [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
forensics:
|
|
51
|
-
|
|
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,
|
|
52
167
|
annotation,
|
|
53
168
|
body,
|
|
54
169
|
frames,
|
|
55
|
-
|
|
56
|
-
|
|
170
|
+
};
|
|
171
|
+
// Settled again at run start, with the file's defaults.
|
|
172
|
+
settle(item, undefined);
|
|
173
|
+
specs.push(item);
|
|
57
174
|
}
|
|
58
175
|
const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
|
|
59
176
|
register("default", title, optionsOrBody, maybeBody);
|
|
@@ -74,22 +191,99 @@ const spec = Object.assign(function specFn(title, optionsOrBody, maybeBody) {
|
|
|
74
191
|
fail(title, optionsOrBody, maybeBody) {
|
|
75
192
|
register("fail", title, optionsOrBody, maybeBody);
|
|
76
193
|
},
|
|
194
|
+
reset(fn) {
|
|
195
|
+
if (typeof fn !== "function")
|
|
196
|
+
throw new TypeError("spec.reset() requires a function");
|
|
197
|
+
resetHooks.push({ frames: captureFrames(), fn });
|
|
198
|
+
},
|
|
199
|
+
afterEach(fn) {
|
|
200
|
+
if (typeof fn !== "function")
|
|
201
|
+
throw new TypeError("spec.afterEach() requires a function");
|
|
202
|
+
afterHooks.push({ frames: captureFrames(), fn });
|
|
203
|
+
},
|
|
77
204
|
beforeEach(fn) {
|
|
78
205
|
if (typeof fn !== "function")
|
|
79
206
|
throw new TypeError("spec.beforeEach() requires a function");
|
|
80
207
|
hooks.push({ frames: captureFrames(), fn });
|
|
81
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
|
+
},
|
|
82
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
|
+
}
|
|
83
231
|
function sourceOf(item) {
|
|
84
232
|
const origin = resolveOrigin(item.frames);
|
|
85
233
|
return { file: origin.file, line: origin.line };
|
|
86
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
|
+
}
|
|
87
279
|
function resolvedId(item) {
|
|
88
280
|
if (item.id && item.id.length > 0)
|
|
89
281
|
return item.id;
|
|
90
282
|
const slug = slugTitle(item.title);
|
|
91
283
|
if (slug)
|
|
92
284
|
return slug;
|
|
285
|
+
if (item.indexInFile === undefined)
|
|
286
|
+
assignFileIndexes();
|
|
93
287
|
return `${fileStem(sourceOf(item).file)}-${item.indexInFile}`;
|
|
94
288
|
}
|
|
95
289
|
/** Report group: the authored file path, trimmed to the project-relative part. */
|
|
@@ -99,27 +293,219 @@ function suiteOf(file) {
|
|
|
99
293
|
const cut = trimmed.lastIndexOf("/tests/");
|
|
100
294
|
return cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
|
|
101
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
|
+
}
|
|
102
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.
|
|
103
330
|
const lx = globalThis.lx;
|
|
104
331
|
if (!lx || typeof lx.automation !== "function") {
|
|
105
|
-
throw new Error("
|
|
332
|
+
throw new Error("The automation root is not available: rawAutomation() runs only inside an lxdev test run");
|
|
106
333
|
}
|
|
107
334
|
return lx.automation();
|
|
108
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
|
+
}
|
|
109
347
|
function pinApp(appId) {
|
|
110
348
|
const automation = automationRoot();
|
|
111
349
|
return appId ? automation.lxapp(appId) : automation.lxapp();
|
|
112
350
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|
|
414
|
+
}
|
|
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 };
|
|
117
450
|
}
|
|
118
|
-
|
|
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) {
|
|
493
|
+
var _a;
|
|
119
494
|
warnVersionSkew();
|
|
120
|
-
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
|
+
};
|
|
121
504
|
const started = Date.now();
|
|
122
505
|
clearInline();
|
|
506
|
+
contractSeq = 0;
|
|
507
|
+
assertAttributed();
|
|
508
|
+
assignFileIndexes();
|
|
123
509
|
const ids = new Map();
|
|
124
510
|
for (const item of specs) {
|
|
125
511
|
const id = resolvedId(item);
|
|
@@ -129,78 +515,305 @@ async function run() {
|
|
|
129
515
|
}
|
|
130
516
|
ids.set(id, item.title);
|
|
131
517
|
}
|
|
132
|
-
const grep =
|
|
133
|
-
const
|
|
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");
|
|
522
|
+
const pattern = grep ? new RegExp(grep) : undefined;
|
|
523
|
+
const forbidOnly = control.forbidOnly === "1" || control["forbid-only"] === "1";
|
|
134
524
|
const hasOnly = specs.some((item) => item.annotation === "only");
|
|
135
525
|
if (hasOnly && forbidOnly) {
|
|
136
526
|
throw new Error("spec.only is registered; lxdev test --forbid-only refuses to run");
|
|
137
527
|
}
|
|
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);
|
|
548
|
+
if (!Number.isInteger(retries) || retries < 0 || retries > 10)
|
|
549
|
+
throw new Error("retries must be between 0 and 10");
|
|
550
|
+
const selectedIds = control.ids ? JSON.parse(control.ids) : undefined;
|
|
551
|
+
const shard = control.shard?.split("/").map(Number);
|
|
552
|
+
if (shard && (shard.length !== 2 || shard.some(n => !Number.isInteger(n) || n < 1) || shard[0] > shard[1]))
|
|
553
|
+
throw new Error("shard must be INDEX/TOTAL (1-based)");
|
|
554
|
+
const locations = parseLocations(control.locations);
|
|
138
555
|
const selected = specs.filter((item) => {
|
|
139
556
|
if (hasOnly && item.annotation !== "only")
|
|
140
557
|
return false;
|
|
558
|
+
if (locations && !atLocation(sourceOf(item), locations))
|
|
559
|
+
return false;
|
|
560
|
+
if (selectedIds && !selectedIds.includes(resolvedId(item)))
|
|
561
|
+
return false;
|
|
562
|
+
if (shard && stableHash(resolvedId(item)) % shard[1] !== shard[0] - 1)
|
|
563
|
+
return false;
|
|
564
|
+
if (control.id && resolvedId(item) !== control.id)
|
|
565
|
+
return false;
|
|
566
|
+
if (tagFilter.length > 0 && !matchesTags(tagsOf(item), tagFilter))
|
|
567
|
+
return false;
|
|
141
568
|
if (!grep)
|
|
142
569
|
return true;
|
|
143
570
|
const id = resolvedId(item);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
571
|
+
return pattern.test(item.title) || pattern.test(id);
|
|
572
|
+
});
|
|
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") {
|
|
577
|
+
throw new Error("No tests matched this selection. Check the entry and filters, or use --pass-with-no-tests.");
|
|
578
|
+
}
|
|
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 ? ", …" : ""}` });
|
|
147
601
|
}
|
|
148
|
-
|
|
149
|
-
|
|
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.` });
|
|
150
615
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
616
|
+
}
|
|
617
|
+
if (retries > 0) {
|
|
618
|
+
for (const item of selected.filter(item => !["skip", "fixme"].includes(item.annotation))) {
|
|
619
|
+
if (!resetHooks.some(hook => hookFiles.get(hook) === sourceOf(item).file)) {
|
|
620
|
+
throw new Error(`Retries require spec.reset() in ${sourceOf(item).file}; relaunching a page does not reset app state.`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
155
624
|
const subject = await describeSubject();
|
|
156
625
|
const cases = [];
|
|
157
626
|
forceRelaunchNext = false;
|
|
158
|
-
|
|
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.
|
|
629
|
+
let contaminated = false;
|
|
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];
|
|
639
|
+
const attempts = new Map();
|
|
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;
|
|
159
648
|
const id = resolvedId(item);
|
|
649
|
+
const key = executionKey(id, repeat);
|
|
160
650
|
const timeout = item.timeout;
|
|
161
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
|
+
}
|
|
162
663
|
const record = {
|
|
163
664
|
id,
|
|
164
665
|
title: item.title,
|
|
165
666
|
name: item.title,
|
|
166
|
-
full_name: item
|
|
667
|
+
full_name: fullName(item, repeat),
|
|
668
|
+
...(repeatEach > 1 ? { repeat } : {}),
|
|
167
669
|
file: source.file,
|
|
168
670
|
line: source.line,
|
|
169
671
|
suite: suiteOf(source.file),
|
|
170
672
|
status: "passed",
|
|
171
673
|
duration_ms: 0,
|
|
172
674
|
covers: [...item.covers],
|
|
675
|
+
tags: tagsOf(item),
|
|
173
676
|
steps: [],
|
|
174
677
|
assertions: [],
|
|
175
678
|
attachments: [],
|
|
176
679
|
timeout_ms: timeout,
|
|
680
|
+
attempt: attempts.get(key)?.length ?? 0,
|
|
177
681
|
reason: item.reason,
|
|
178
682
|
};
|
|
179
683
|
await host.emit({
|
|
180
684
|
type: "case_started",
|
|
685
|
+
id, file: source.file, line: source.line,
|
|
181
686
|
name: record.name,
|
|
182
687
|
full_name: record.full_name,
|
|
183
688
|
timeout_ms: timeout,
|
|
689
|
+
watchdog_timeout_ms: timeout + (item.timeoutCleanup ?? MAX_DEFER_BUDGET_MS) + FORENSICS_BUDGET_MS + WEDGED_DEFER_BUDGET_MS,
|
|
184
690
|
covers: record.covers,
|
|
691
|
+
tags: record.tags,
|
|
185
692
|
});
|
|
186
693
|
const caseStarted = Date.now();
|
|
187
|
-
|
|
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") {
|
|
698
|
+
if (contaminated)
|
|
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;
|
|
188
706
|
record.status = "skipped";
|
|
189
707
|
record.duration_ms = Date.now() - caseStarted;
|
|
190
708
|
cases.push(record);
|
|
191
709
|
await finishCase(host, record);
|
|
192
710
|
continue;
|
|
193
711
|
}
|
|
194
|
-
|
|
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
|
+
}
|
|
195
745
|
let status = "passed";
|
|
196
746
|
let error;
|
|
197
747
|
let phase = "body";
|
|
198
|
-
|
|
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;
|
|
199
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
|
+
};
|
|
200
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
|
+
}
|
|
201
810
|
if (shouldRelaunch)
|
|
202
|
-
await relaunchHome(fixture.raw);
|
|
811
|
+
await relaunchHome(fixture.raw, item.start);
|
|
203
812
|
phase = "beforeEach";
|
|
813
|
+
for (const hook of resetHooks) {
|
|
814
|
+
if (hookFiles.get(hook) === source.file)
|
|
815
|
+
await hook.fn(fixture);
|
|
816
|
+
}
|
|
204
817
|
for (const hook of hooks) {
|
|
205
818
|
if (hookFiles.get(hook) === source.file)
|
|
206
819
|
await hook.fn(fixture);
|
|
@@ -211,99 +824,329 @@ async function run() {
|
|
|
211
824
|
const bodyResult = bodyPromise.then(() => ({ ok: true }), (err) => ({ ok: false, err }));
|
|
212
825
|
const timeoutError = new TimeoutError(`spec timed out after ${timeout}ms`);
|
|
213
826
|
let timedOut = false;
|
|
827
|
+
let bodySettled = false;
|
|
828
|
+
void bodyResult.then(() => { bodySettled = true; });
|
|
214
829
|
let timerHandle;
|
|
215
830
|
const timer = new Promise((resolve) => {
|
|
216
|
-
timerHandle =
|
|
831
|
+
timerHandle = runnerSetTimeout(() => {
|
|
217
832
|
timedOut = true;
|
|
218
833
|
fixture.abort(timeoutError);
|
|
219
834
|
resolve("timeout");
|
|
220
835
|
}, timeout);
|
|
221
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 }));
|
|
222
840
|
try {
|
|
223
|
-
const winner = await Promise.race([bodyResult, timer]);
|
|
224
|
-
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") {
|
|
225
857
|
status = "timeout";
|
|
226
858
|
error = timeoutError;
|
|
227
859
|
phase = "timeout";
|
|
228
860
|
forceRelaunchNext = true;
|
|
229
|
-
|
|
230
|
-
bodyResult,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
861
|
+
try {
|
|
862
|
+
await within(bodyResult, WEDGED_DEFER_BUDGET_MS, "body did not settle after timeout");
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
const work = stuckWork();
|
|
866
|
+
abandon("its timed-out body", work);
|
|
867
|
+
timeoutError.message += `\nThe body did not settle; still pending: ${describePending(work)}.`;
|
|
868
|
+
}
|
|
235
869
|
}
|
|
236
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
|
+
}
|
|
237
877
|
if (timedOut || fixture.aborted) {
|
|
238
878
|
status = "timeout";
|
|
239
879
|
error = timeoutError;
|
|
240
880
|
phase = "timeout";
|
|
241
881
|
forceRelaunchNext = true;
|
|
242
882
|
}
|
|
883
|
+
else if (winner.err instanceof SkipSignal) {
|
|
884
|
+
status = "skipped";
|
|
885
|
+
}
|
|
243
886
|
else {
|
|
244
887
|
status = "failed";
|
|
245
888
|
error = winner.err;
|
|
246
889
|
fixture.failurePhase = phase;
|
|
247
890
|
}
|
|
248
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
|
+
}
|
|
249
908
|
}
|
|
250
909
|
finally {
|
|
251
910
|
if (timerHandle !== undefined)
|
|
252
|
-
|
|
911
|
+
runnerClearTimeout(timerHandle);
|
|
253
912
|
}
|
|
254
|
-
|
|
255
|
-
|
|
913
|
+
let evidenceCollected = false;
|
|
914
|
+
let failurePage;
|
|
915
|
+
const collectEvidence = async () => {
|
|
916
|
+
if (evidenceCollected || !item.forensics)
|
|
917
|
+
return;
|
|
918
|
+
evidenceCollected = true;
|
|
256
919
|
try {
|
|
257
920
|
// The whole point of the timeout path is that a wedged app does not
|
|
258
921
|
// stall the run, and these calls bypass `guard` on the raw driver.
|
|
259
|
-
await
|
|
260
|
-
captureForensics(fixture),
|
|
261
|
-
new Promise((resolve) => {
|
|
262
|
-
setTimeout(resolve, FORENSICS_BUDGET_MS);
|
|
263
|
-
}),
|
|
264
|
-
]);
|
|
922
|
+
failurePage = await within(captureForensics(fixture), FORENSICS_BUDGET_MS, "failure evidence timed out");
|
|
265
923
|
}
|
|
266
924
|
catch (forensicsError) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
fixture.failurePhase = "forensics";
|
|
925
|
+
// Evidence failures must never replace the product failure.
|
|
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 });
|
|
271
928
|
}
|
|
929
|
+
await host.emit({ type: "diagnostic", phase: "forensics", message: String(forensicsError) });
|
|
272
930
|
}
|
|
273
|
-
}
|
|
931
|
+
};
|
|
932
|
+
if (status !== "passed" && status !== "skipped")
|
|
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
|
+
};
|
|
274
945
|
phase = "defer";
|
|
275
946
|
const deferErrors = [];
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
947
|
+
const cleanupBudget = item.timeoutCleanup ?? (status === "timeout" ? WEDGED_DEFER_BUDGET_MS : MAX_DEFER_BUDGET_MS);
|
|
948
|
+
const afterEach = afterHooks.filter(hook => hookFiles.get(hook) === source.file);
|
|
949
|
+
const hasCleanup = afterEach.length > 0 || fixture.defers.length > 0;
|
|
950
|
+
function* cleanupTasks() {
|
|
951
|
+
for (const hook of afterEach)
|
|
952
|
+
yield () => hook.fn(fixture);
|
|
953
|
+
while (fixture.defers.length > 0)
|
|
954
|
+
yield fixture.defers.pop();
|
|
955
|
+
}
|
|
956
|
+
// Never reopen a timed-out fixture while its body can still issue commands.
|
|
957
|
+
if (hasCleanup && bodySettled) {
|
|
958
|
+
fixture.allowCleanup(cleanupBudget);
|
|
959
|
+
const deadline = Date.now() + cleanupBudget;
|
|
960
|
+
for (const cleanup of cleanupTasks()) {
|
|
961
|
+
let settled = false;
|
|
962
|
+
const task = Promise.resolve().then(cleanup).finally(() => { settled = true; });
|
|
963
|
+
try {
|
|
964
|
+
await within(task, Math.max(0, deadline - Date.now()), "fixture cleanup budget exceeded");
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
deferErrors.push(err);
|
|
968
|
+
}
|
|
969
|
+
if (!settled) {
|
|
970
|
+
abandon("its cleanup (t.defer / afterEach)", stuckWork());
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
else if (hasCleanup) {
|
|
976
|
+
deferErrors.push(new Error("Cleanup skipped because the timed-out body is still running"));
|
|
977
|
+
}
|
|
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;
|
|
286
1100
|
try {
|
|
287
|
-
await fixture
|
|
1101
|
+
checked = await checkContract(ledger, fixture, id, host);
|
|
288
1102
|
}
|
|
289
|
-
catch (
|
|
290
|
-
|
|
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
|
+
}
|
|
291
1129
|
}
|
|
292
1130
|
}
|
|
293
1131
|
if (item.annotation === "fail") {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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;
|
|
299
1143
|
}
|
|
300
1144
|
else if (status === "passed") {
|
|
301
1145
|
status = "xpass";
|
|
302
1146
|
error = new Error("spec.fail passed (xpass)");
|
|
1147
|
+
fixture.failurePhase = "body";
|
|
303
1148
|
}
|
|
304
1149
|
}
|
|
305
|
-
if (fixture.defers.length > 0)
|
|
306
|
-
fixture.endCleanup();
|
|
307
1150
|
if (deferErrors.length > 0) {
|
|
308
1151
|
// A spec that hung is reported as a timeout; cleanup that could not
|
|
309
1152
|
// finish afterwards is a consequence of the hang, not a different
|
|
@@ -322,105 +1165,513 @@ async function run() {
|
|
|
322
1165
|
error = new Error(`t.defer failed:\n${cleanupText}`);
|
|
323
1166
|
}
|
|
324
1167
|
}
|
|
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")
|
|
1200
|
+
await collectEvidence();
|
|
1201
|
+
if (status === "skipped")
|
|
1202
|
+
record.reason = fixture.skipReason ?? record.reason;
|
|
1203
|
+
fixture.close();
|
|
325
1204
|
record.status = status;
|
|
326
1205
|
record.duration_ms = Date.now() - caseStarted;
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
1230
|
if (fixture.observed.size > 0)
|
|
330
1231
|
record.observed = [...fixture.observed].sort();
|
|
331
|
-
record.attachments = fixture.attachments;
|
|
332
|
-
|
|
333
|
-
if (error
|
|
1232
|
+
record.attachments = [...fixture.attachments];
|
|
1233
|
+
setExpectScope();
|
|
1234
|
+
if (error)
|
|
334
1235
|
record.error = toReportError(error, fixture.currentStepPath());
|
|
1236
|
+
if (record.error) {
|
|
1237
|
+
record.error.phase = status === "timeout" ? "timeout" : fixture.failurePhase ?? phase;
|
|
1238
|
+
// JSC tail calls can omit the authored assertion frame.
|
|
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;
|
|
335
1255
|
}
|
|
336
|
-
|
|
337
|
-
|
|
1256
|
+
const history = attempts.get(key) ?? [];
|
|
1257
|
+
history.push(record);
|
|
1258
|
+
attempts.set(key, history);
|
|
1259
|
+
const finished = { ...record, attempts: history.length > 1 ? history.map(attempt => ({ ...attempt })) : undefined,
|
|
1260
|
+
flaky: record.status === "passed" && history.length > 1 };
|
|
1261
|
+
const previous = cases.findIndex(item => item.id === id && (item.repeat ?? 1) === repeat);
|
|
1262
|
+
if (previous >= 0)
|
|
1263
|
+
cases[previous] = finished;
|
|
1264
|
+
else
|
|
1265
|
+
cases.push(finished);
|
|
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);
|
|
1322
|
+
if (!contaminated && (status === "failed" || status === "timeout") && history.length <= retries) {
|
|
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.") });
|
|
338
1338
|
}
|
|
339
|
-
cases.push(record);
|
|
340
|
-
await finishCase(host, record);
|
|
341
1339
|
}
|
|
342
1340
|
const counts = countStatuses(cases);
|
|
343
1341
|
const duration_ms = Date.now() - started;
|
|
1342
|
+
const reportedControl = { ...control };
|
|
1343
|
+
for (const key of PAYLOAD_CONTROL_KEYS)
|
|
1344
|
+
delete reportedControl[key];
|
|
344
1345
|
const json = {
|
|
1346
|
+
schema_version: 1,
|
|
345
1347
|
framework: { name: PACKAGE_NAME, version: VERSION },
|
|
346
1348
|
meta: {
|
|
347
1349
|
started_at: new Date(started).toISOString(),
|
|
348
1350
|
duration_ms,
|
|
349
|
-
args:
|
|
350
|
-
|
|
351
|
-
|
|
1351
|
+
args: redact.args(args),
|
|
1352
|
+
run: redact.deep(reportedControl),
|
|
1353
|
+
platform: control.platform,
|
|
1354
|
+
framework: args.framework,
|
|
352
1355
|
subject,
|
|
353
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 } : {}),
|
|
354
1361
|
},
|
|
355
|
-
partial:
|
|
356
|
-
filtered: Boolean(grep) || hasOnly,
|
|
1362
|
+
partial: contaminated || budgetExhausted !== undefined || screenLocked !== undefined,
|
|
1363
|
+
filtered: Boolean(grep || control.id || control.ids || shard || locations || tagFilter.length > 0) || hasOnly,
|
|
357
1364
|
duration_ms,
|
|
358
1365
|
...counts,
|
|
359
|
-
cases,
|
|
1366
|
+
cases: redact.deep(cases),
|
|
360
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);
|
|
361
1377
|
await attachText(host, "report.json", JSON.stringify(json, null, 2), "application/json");
|
|
362
1378
|
await attachText(host, "report.html", renderHtml(json), "text/html; charset=utf-8");
|
|
363
1379
|
await attachText(host, "junit.xml", renderJUnit(json), "application/xml; charset=utf-8");
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
1380
|
+
return json;
|
|
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}`);
|
|
386
1418
|
}
|
|
387
1419
|
async function finishCase(host, record) {
|
|
388
1420
|
await host.emit({
|
|
389
1421
|
type: "case_finished",
|
|
390
1422
|
name: record.name,
|
|
391
1423
|
full_name: record.full_name,
|
|
392
|
-
status:
|
|
1424
|
+
status: record.status,
|
|
1425
|
+
id: record.id,
|
|
393
1426
|
duration_ms: record.duration_ms,
|
|
394
|
-
error: record.error
|
|
395
|
-
|
|
396
|
-
: undefined,
|
|
1427
|
+
error: record.error,
|
|
1428
|
+
record,
|
|
397
1429
|
});
|
|
398
1430
|
}
|
|
399
|
-
/**
|
|
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
|
+
*/
|
|
400
1439
|
async function describeSubject() {
|
|
401
|
-
|
|
402
|
-
|
|
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;
|
|
403
1493
|
return {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 } : {}),
|
|
409
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) : [];
|
|
410
1521
|
}
|
|
411
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`);
|
|
412
1606
|
return undefined;
|
|
413
1607
|
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
return errorMessage(error);
|
|
1610
|
+
}
|
|
414
1611
|
}
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
};
|
|
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;
|
|
417
1667
|
}
|
|
1668
|
+
/** Attach failure evidence; resolves to the page that was current, if known. */
|
|
418
1669
|
async function captureForensics(fixture) {
|
|
419
1670
|
try {
|
|
420
1671
|
const shot = await fixture.raw.page.screenshot();
|
|
421
1672
|
const payload = encodeScreenshot(shot);
|
|
422
1673
|
if (payload) {
|
|
423
|
-
await fixture.attachRaw("failure.png", payload);
|
|
1674
|
+
await fixture.attachRaw("failure.png", payload, "failure_evidence");
|
|
424
1675
|
}
|
|
425
1676
|
}
|
|
426
1677
|
catch {
|
|
@@ -440,16 +1691,29 @@ async function captureForensics(fixture) {
|
|
|
440
1691
|
catch {
|
|
441
1692
|
info = undefined;
|
|
442
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
|
+
}
|
|
443
1703
|
const forensics = {
|
|
444
1704
|
route,
|
|
445
1705
|
info,
|
|
446
1706
|
step: fixture.currentStepPath() ?? null,
|
|
1707
|
+
visibility: visibility ?? null,
|
|
447
1708
|
};
|
|
448
|
-
await fixture.attachRaw("forensics.json", forensics);
|
|
1709
|
+
await fixture.attachRaw("forensics.json", forensics, "failure_evidence");
|
|
449
1710
|
const logs = await fixtureHostLogs();
|
|
450
1711
|
if (logs !== undefined) {
|
|
451
|
-
await fixture.attachRaw("logs.txt", logs);
|
|
1712
|
+
await fixture.attachRaw("logs.txt", logs, "failure_evidence");
|
|
452
1713
|
}
|
|
1714
|
+
const page = toFailurePage(route);
|
|
1715
|
+
const hidden = hiddenCause(visibility?.note);
|
|
1716
|
+
return page && hidden ? { ...page, hidden } : page;
|
|
453
1717
|
}
|
|
454
1718
|
async function fixtureHostLogs() {
|
|
455
1719
|
const host = resolveHost();
|
|
@@ -465,15 +1729,23 @@ function encodeScreenshot(shot) {
|
|
|
465
1729
|
}
|
|
466
1730
|
function reset() {
|
|
467
1731
|
specs.length = 0;
|
|
1732
|
+
fileConfigs.length = 0;
|
|
1733
|
+
setActiveOpenApi(undefined);
|
|
468
1734
|
hooks.length = 0;
|
|
1735
|
+
afterHooks.length = 0;
|
|
1736
|
+
resetHooks.length = 0;
|
|
469
1737
|
trackSurface = false;
|
|
470
|
-
fileCounts.clear();
|
|
471
1738
|
forceRelaunchNext = false;
|
|
1739
|
+
uninstallPendingTracker();
|
|
1740
|
+
unfenceGlobalAutomation();
|
|
1741
|
+
restoreRawAuthority();
|
|
472
1742
|
clearInline();
|
|
473
|
-
|
|
1743
|
+
setExpectScope();
|
|
1744
|
+
setStrayAssertionSink();
|
|
474
1745
|
}
|
|
475
1746
|
const controller = {
|
|
476
1747
|
run,
|
|
1748
|
+
list,
|
|
477
1749
|
version: VERSION,
|
|
478
1750
|
reset,
|
|
479
1751
|
};
|
|
@@ -485,5 +1757,94 @@ if (!globalThis.__LINGXIA_TEST__) {
|
|
|
485
1757
|
writable: false,
|
|
486
1758
|
});
|
|
487
1759
|
}
|
|
488
|
-
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
|
+
}
|
|
1792
|
+
async function within(task, ms, message) {
|
|
1793
|
+
let handle;
|
|
1794
|
+
try {
|
|
1795
|
+
return await Promise.race([task, new Promise((_, reject) => {
|
|
1796
|
+
handle = runnerSetTimeout(() => reject(new TimeoutError(message)), ms);
|
|
1797
|
+
})]);
|
|
1798
|
+
}
|
|
1799
|
+
finally {
|
|
1800
|
+
if (handle !== undefined)
|
|
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);
|
|
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
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
function stableHash(value) {
|
|
1845
|
+
let hash = 2166136261;
|
|
1846
|
+
for (let i = 0; i < value.length; i++)
|
|
1847
|
+
hash = Math.imul(hash ^ value.charCodeAt(i), 16777619);
|
|
1848
|
+
return hash >>> 0;
|
|
1849
|
+
}
|
|
489
1850
|
//# sourceMappingURL=runtime.js.map
|