@wdio/nightwatch-devtools 1.1.0 → 1.2.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/README.md +65 -1
- package/dist/bidi-OH4ZFDN5.js +215 -0
- package/dist/bidi-OH4ZFDN5.js.map +1 -0
- package/dist/chunk-DKSBYMPM.js +191 -0
- package/dist/chunk-DKSBYMPM.js.map +1 -0
- package/dist/index.d.ts +106 -16
- package/dist/index.js +3875 -760
- package/dist/index.js.map +1 -1
- package/package.json +18 -13
- package/dist/constants.d.ts +0 -55
- package/dist/constants.js +0 -95
- package/dist/constants.js.map +0 -1
- package/dist/helpers/browserProxy.d.ts +0 -46
- package/dist/helpers/browserProxy.js +0 -314
- package/dist/helpers/browserProxy.js.map +0 -1
- package/dist/helpers/capturePerformance.d.ts +0 -5
- package/dist/helpers/capturePerformance.js +0 -53
- package/dist/helpers/capturePerformance.js.map +0 -1
- package/dist/helpers/cucumberHooks.cjs +0 -54
- package/dist/helpers/cucumberHooks.cjs.map +0 -1
- package/dist/helpers/cucumberHooks.d.cts +0 -21
- package/dist/helpers/suiteManager.d.ts +0 -43
- package/dist/helpers/suiteManager.js +0 -143
- package/dist/helpers/suiteManager.js.map +0 -1
- package/dist/helpers/testManager.d.ts +0 -45
- package/dist/helpers/testManager.js +0 -143
- package/dist/helpers/testManager.js.map +0 -1
- package/dist/helpers/utils.d.ts +0 -83
- package/dist/helpers/utils.js +0 -447
- package/dist/helpers/utils.js.map +0 -1
- package/dist/reporter.d.ts +0 -44
- package/dist/reporter.js +0 -185
- package/dist/reporter.js.map +0 -1
- package/dist/session.d.ts +0 -78
- package/dist/session.js +0 -643
- package/dist/session.js.map +0 -1
- package/dist/types.d.ts +0 -190
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,797 +1,3912 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
import {
|
|
2
|
+
CONSOLE_METHODS,
|
|
3
|
+
LOG_SOURCES,
|
|
4
|
+
SPINNER_RE,
|
|
5
|
+
createConsoleLogEntry,
|
|
6
|
+
detectLogLevel,
|
|
7
|
+
errorMessage,
|
|
8
|
+
findFreePort,
|
|
9
|
+
getRequestType,
|
|
10
|
+
isInternalStreamLine,
|
|
11
|
+
mapChromeBrowserLogs,
|
|
12
|
+
serializeError,
|
|
13
|
+
stripAnsi,
|
|
14
|
+
toError
|
|
15
|
+
} from "./chunk-DKSBYMPM.js";
|
|
16
|
+
|
|
17
|
+
// src/index.ts
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
|
|
20
|
+
// ../core/src/stack.ts
|
|
21
|
+
import { parse as parseStackTrace } from "stacktrace-parser";
|
|
22
|
+
function isUserCodeFrame(frame) {
|
|
23
|
+
const { file } = frame;
|
|
24
|
+
return !!(file && !file.includes("/node_modules/") && !file.includes("<anonymous>") && !file.includes("node:internal") && !file.includes("/dist/") && !file.endsWith("/index.js"));
|
|
25
|
+
}
|
|
26
|
+
function normalizeFilePath(filePath) {
|
|
27
|
+
const stripped = filePath.replace(/^file:\/\//, "").split(":")[0];
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(stripped);
|
|
30
|
+
} catch {
|
|
31
|
+
return stripped;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function getCallSourceFromStack() {
|
|
35
|
+
const stack = new Error().stack;
|
|
36
|
+
if (!stack) {
|
|
37
|
+
return { filePath: void 0, callSource: "unknown:0" };
|
|
38
|
+
}
|
|
39
|
+
const frame = parseStackTrace(stack).find(isUserCodeFrame);
|
|
40
|
+
if (!frame?.file) {
|
|
41
|
+
return { filePath: void 0, callSource: "unknown:0" };
|
|
42
|
+
}
|
|
43
|
+
const filePath = normalizeFilePath(frame.file);
|
|
44
|
+
return { filePath, callSource: `${filePath}:${frame.lineNumber ?? 0}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ../core/src/uid.ts
|
|
48
|
+
function deterministicUid(...parts) {
|
|
49
|
+
const hash = parts.join("::").split("").reduce((acc, char) => (acc << 5) - acc + char.charCodeAt(0) | 0, 0);
|
|
50
|
+
return `stable-${Math.abs(hash).toString(36)}`;
|
|
51
|
+
}
|
|
52
|
+
var signatureCounters = /* @__PURE__ */ new Map();
|
|
53
|
+
function generateStableUid(file, name) {
|
|
54
|
+
const signature = `${file}::${name}`;
|
|
55
|
+
const count = signatureCounters.get(signature) ?? 0;
|
|
56
|
+
signatureCounters.set(signature, count + 1);
|
|
57
|
+
const input = count > 0 ? `${signature}::${count}` : signature;
|
|
58
|
+
return deterministicUid(input);
|
|
59
|
+
}
|
|
60
|
+
function resetSignatureCounters() {
|
|
61
|
+
signatureCounters.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ../core/src/finalize-screencast.ts
|
|
65
|
+
import fs2 from "fs";
|
|
66
|
+
import os2 from "os";
|
|
67
|
+
import path2 from "path";
|
|
68
|
+
|
|
69
|
+
// ../core/src/video-encoder.ts
|
|
70
|
+
import fs from "fs/promises";
|
|
71
|
+
import path from "path";
|
|
72
|
+
import os from "os";
|
|
73
|
+
import { createRequire } from "module";
|
|
74
|
+
var require2 = createRequire(import.meta.url);
|
|
75
|
+
function loadFfmpeg() {
|
|
76
|
+
try {
|
|
77
|
+
return require2("fluent-ffmpeg");
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"VideoEncoder: fluent-ffmpeg is required for screencast encoding. Install it with: npm install fluent-ffmpeg"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function writeFramesAndManifest(frames, tmpDir, ext) {
|
|
85
|
+
const manifestLines = ["ffconcat version 1.0"];
|
|
86
|
+
for (let i = 0; i < frames.length; i++) {
|
|
87
|
+
const frameName = `frame-${String(i).padStart(6, "0")}.${ext}`;
|
|
88
|
+
const framePath = path.join(tmpDir, frameName);
|
|
89
|
+
await fs.writeFile(framePath, Buffer.from(frames[i].data, "base64"));
|
|
90
|
+
const nextTs = frames[i + 1]?.timestamp ?? frames[i].timestamp + 100;
|
|
91
|
+
const durationSecs = Math.max((nextTs - frames[i].timestamp) / 1e3, 0.01);
|
|
92
|
+
manifestLines.push(`file '${framePath}'`);
|
|
93
|
+
manifestLines.push(`duration ${durationSecs.toFixed(6)}`);
|
|
94
|
+
}
|
|
95
|
+
const lastFramePath = path.join(
|
|
96
|
+
tmpDir,
|
|
97
|
+
`frame-${String(frames.length - 1).padStart(6, "0")}.${ext}`
|
|
98
|
+
);
|
|
99
|
+
manifestLines.push(`file '${lastFramePath}'`);
|
|
100
|
+
const manifestPath = path.join(tmpDir, "manifest.txt");
|
|
101
|
+
await fs.writeFile(manifestPath, manifestLines.join("\n"));
|
|
102
|
+
return manifestPath;
|
|
103
|
+
}
|
|
104
|
+
function classifyFfmpegError(err) {
|
|
105
|
+
const msg = err.message || "";
|
|
106
|
+
if (msg.includes("Cannot find ffmpeg") || msg.includes("ENOENT") || msg.includes("spawn") || msg.includes("not found")) {
|
|
107
|
+
return new Error(
|
|
108
|
+
"VideoEncoder: ffmpeg binary not found on PATH. Install ffmpeg: https://ffmpeg.org/download.html"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return new Error(`VideoEncoder: ffmpeg error \u2014 ${msg}`);
|
|
112
|
+
}
|
|
113
|
+
function runFfmpeg(ffmpeg, manifestPath, outputPath) {
|
|
114
|
+
return new Promise((resolve2, reject) => {
|
|
115
|
+
ffmpeg().input(manifestPath).inputOptions(["-f", "concat", "-safe", "0"]).videoCodec("libvpx").outputOptions([
|
|
116
|
+
"-b:v",
|
|
117
|
+
"1M",
|
|
118
|
+
"-pix_fmt",
|
|
119
|
+
"yuv420p",
|
|
120
|
+
"-vsync",
|
|
121
|
+
"cfr",
|
|
122
|
+
"-r",
|
|
123
|
+
"10",
|
|
124
|
+
"-auto-alt-ref",
|
|
125
|
+
"0",
|
|
126
|
+
"-disposition:v",
|
|
127
|
+
"default"
|
|
128
|
+
]).output(outputPath).on("end", () => resolve2()).on("error", (err) => reject(classifyFfmpegError(err))).run();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function encodeToVideo(frames, outputPath, options = {}) {
|
|
132
|
+
if (frames.length === 0) {
|
|
133
|
+
throw new Error("VideoEncoder: no frames to encode");
|
|
134
|
+
}
|
|
135
|
+
const ffmpeg = loadFfmpeg();
|
|
136
|
+
const ext = options.captureFormat === "png" ? "png" : "jpg";
|
|
137
|
+
const tmpDir = await fs.mkdtemp(
|
|
138
|
+
path.join(os.tmpdir(), "devtools-screencast-")
|
|
139
|
+
);
|
|
140
|
+
try {
|
|
141
|
+
const manifestPath = await writeFramesAndManifest(frames, tmpDir, ext);
|
|
142
|
+
await runFfmpeg(ffmpeg, manifestPath, outputPath);
|
|
143
|
+
} finally {
|
|
144
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ../core/src/finalize-screencast.ts
|
|
150
|
+
async function finalizeScreencast({
|
|
151
|
+
recorder,
|
|
152
|
+
sessionId,
|
|
153
|
+
filenamePrefix,
|
|
154
|
+
outputDir,
|
|
155
|
+
minFrames = 1,
|
|
156
|
+
captureFormat,
|
|
157
|
+
sendUpstream,
|
|
158
|
+
onLog
|
|
159
|
+
}) {
|
|
160
|
+
const log12 = (level, message) => onLog?.(level, message);
|
|
161
|
+
try {
|
|
162
|
+
await recorder.stop();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log12("warn", `Screencast stop failed: ${errorMessage(err)}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const frames = recorder.frames;
|
|
168
|
+
if (frames.length < minFrames) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const fileName = `${filenamePrefix}-${sessionId}.webm`;
|
|
172
|
+
const candidate = outputDir || process.cwd();
|
|
173
|
+
let videoPath = path2.join(candidate, fileName);
|
|
174
|
+
try {
|
|
175
|
+
fs2.accessSync(candidate, fs2.constants.W_OK);
|
|
176
|
+
} catch {
|
|
177
|
+
videoPath = path2.join(os2.tmpdir(), fileName);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await encodeToVideo(frames, videoPath, { captureFormat });
|
|
181
|
+
log12("info", `\u{1F4F9} Screencast video: ${videoPath}`);
|
|
182
|
+
sendUpstream("screencast", {
|
|
183
|
+
sessionId,
|
|
184
|
+
videoPath,
|
|
185
|
+
videoFile: fileName,
|
|
186
|
+
frameCount: frames.length,
|
|
187
|
+
duration: recorder.duration
|
|
188
|
+
});
|
|
189
|
+
} catch (err) {
|
|
190
|
+
log12("warn", `Screencast encode failed: ${errorMessage(err)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ../core/src/output-dir.ts
|
|
195
|
+
import fs3 from "fs";
|
|
196
|
+
import path3 from "path";
|
|
197
|
+
var NODE_MODULES_SEGMENT = `${path3.sep}node_modules${path3.sep}`;
|
|
198
|
+
function isWritable(dir) {
|
|
199
|
+
try {
|
|
200
|
+
fs3.accessSync(dir, fs3.constants.W_OK);
|
|
201
|
+
return true;
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function resolveAdapterOutputDir(input = {}) {
|
|
207
|
+
const fallback = input.fallbackDir ?? process.cwd();
|
|
208
|
+
if (input.userConfiguredDir) {
|
|
209
|
+
return input.userConfiguredDir;
|
|
210
|
+
}
|
|
211
|
+
const candidates = [];
|
|
212
|
+
if (input.testFilePath) {
|
|
213
|
+
candidates.push(path3.dirname(input.testFilePath));
|
|
214
|
+
}
|
|
215
|
+
if (input.configPath) {
|
|
216
|
+
candidates.push(path3.dirname(input.configPath));
|
|
217
|
+
}
|
|
218
|
+
candidates.push(fallback);
|
|
219
|
+
for (const dir of candidates) {
|
|
220
|
+
if (!dir) {
|
|
221
|
+
continue;
|
|
53
222
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
log.warn('Could not find nightwatch config — test rerun will be unavailable');
|
|
223
|
+
if (dir.includes(NODE_MODULES_SEGMENT)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (isWritable(dir)) {
|
|
227
|
+
return dir;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ../core/src/performance-capture.ts
|
|
234
|
+
var CAPTURE_PERFORMANCE_SCRIPT = `
|
|
235
|
+
(function() {
|
|
236
|
+
const performance = window.performance;
|
|
237
|
+
const navigation = performance.getEntriesByType?.('navigation')?.[0];
|
|
238
|
+
const resources = performance.getEntriesByType?.('resource') || [];
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
navigation: navigation ? {
|
|
242
|
+
url: window.location.href,
|
|
243
|
+
timing: {
|
|
244
|
+
loadTime: navigation.loadEventEnd - navigation.fetchStart,
|
|
245
|
+
domReady: navigation.domContentLoadedEventEnd - navigation.fetchStart,
|
|
246
|
+
responseTime: navigation.responseEnd - navigation.requestStart,
|
|
247
|
+
dnsLookup: navigation.domainLookupEnd - navigation.domainLookupStart,
|
|
248
|
+
tcpConnection: navigation.connectEnd - navigation.connectStart,
|
|
249
|
+
serverResponse: navigation.responseEnd - navigation.responseStart
|
|
82
250
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
251
|
+
} : undefined,
|
|
252
|
+
resources: resources.map(function(resource) {
|
|
253
|
+
return {
|
|
254
|
+
url: resource.name,
|
|
255
|
+
duration: resource.duration,
|
|
256
|
+
size: resource.transferSize || 0,
|
|
257
|
+
type: resource.initiatorType,
|
|
258
|
+
startTime: resource.startTime,
|
|
259
|
+
responseEnd: resource.responseEnd
|
|
260
|
+
};
|
|
261
|
+
}),
|
|
262
|
+
cookies: (function() {
|
|
263
|
+
try { return document.cookie; } catch (e) { return ''; }
|
|
264
|
+
})(),
|
|
265
|
+
documentInfo: {
|
|
266
|
+
url: window.location.href,
|
|
267
|
+
title: document.title,
|
|
268
|
+
headers: {
|
|
269
|
+
userAgent: navigator.userAgent,
|
|
270
|
+
language: navigator.language,
|
|
271
|
+
platform: navigator.platform
|
|
272
|
+
},
|
|
273
|
+
documentInfo: {
|
|
274
|
+
readyState: document.readyState,
|
|
275
|
+
referrer: document.referrer,
|
|
276
|
+
characterSet: document.characterSet
|
|
88
277
|
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
})()
|
|
281
|
+
`;
|
|
282
|
+
function applyPerformanceData(command, payload, navigatedUrl) {
|
|
283
|
+
if (!payload || !payload.navigation) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
command.performance = {
|
|
287
|
+
navigation: payload.navigation,
|
|
288
|
+
resources: payload.resources
|
|
289
|
+
};
|
|
290
|
+
command.cookies = payload.cookies;
|
|
291
|
+
command.documentInfo = payload.documentInfo;
|
|
292
|
+
command.result = {
|
|
293
|
+
url: navigatedUrl,
|
|
294
|
+
loadTime: payload.navigation?.timing?.loadTime,
|
|
295
|
+
resources: payload.resources,
|
|
296
|
+
resourceCount: payload.resources?.length,
|
|
297
|
+
cookies: payload.cookies,
|
|
298
|
+
title: payload.documentInfo?.title
|
|
299
|
+
};
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ../core/src/retry-tracker.ts
|
|
304
|
+
var RetryTracker = class {
|
|
305
|
+
#lastSig = null;
|
|
306
|
+
#lastId = null;
|
|
307
|
+
/** Build the canonical signature used for retry-equality checks. */
|
|
308
|
+
static signature(command, args, callSource) {
|
|
309
|
+
return JSON.stringify({ command, args, src: callSource ?? null });
|
|
310
|
+
}
|
|
311
|
+
/** True when the incoming signature matches the last captured one AND we
|
|
312
|
+
* have an id to replace (otherwise there's nothing to replace yet). */
|
|
313
|
+
isRetry(sig) {
|
|
314
|
+
return sig === this.#lastSig && this.#lastId !== null;
|
|
315
|
+
}
|
|
316
|
+
/** The id of the last captured command, if any (for the replace-in-place
|
|
317
|
+
* flow). */
|
|
318
|
+
get lastId() {
|
|
319
|
+
return this.#lastId;
|
|
320
|
+
}
|
|
321
|
+
/** Record a fresh capture — sets both sig and id together. */
|
|
322
|
+
recordCapture(sig, id) {
|
|
323
|
+
this.#lastSig = sig;
|
|
324
|
+
this.#lastId = id;
|
|
325
|
+
}
|
|
326
|
+
/** Record only the id (used by adapters that compute the sig but defer the
|
|
327
|
+
* id assignment to after an async capture call). */
|
|
328
|
+
setLastId(id) {
|
|
329
|
+
this.#lastId = id;
|
|
330
|
+
}
|
|
331
|
+
/** Stage the sig before an async capture so the next call already sees the
|
|
332
|
+
* signature change (prevents stale-sig matches on rapid back-to-back
|
|
333
|
+
* commands). Pair with {@link setLastId} once the capture resolves. */
|
|
334
|
+
setLastSig(sig) {
|
|
335
|
+
this.#lastSig = sig;
|
|
336
|
+
}
|
|
337
|
+
/** Reset at test/scenario boundaries so the next capture is "fresh". */
|
|
338
|
+
reset() {
|
|
339
|
+
this.#lastSig = null;
|
|
340
|
+
this.#lastId = null;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ../shared/src/files.ts
|
|
345
|
+
var SPEC_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i;
|
|
346
|
+
|
|
347
|
+
// ../shared/src/routes.ts
|
|
348
|
+
var WS_PATHS = {
|
|
349
|
+
/** Adapter session upgrade endpoint. One socket per running adapter. */
|
|
350
|
+
worker: "/worker",
|
|
351
|
+
/** App/UI client upgrade endpoint. Multiple browser tabs may connect. */
|
|
352
|
+
client: "/client"
|
|
353
|
+
};
|
|
354
|
+
var WS_SCOPE = {
|
|
355
|
+
/** Backend → worker: a dashboard client has subscribed. Wakes up the
|
|
356
|
+
* adapter's `await UI ready` gate before tests start. */
|
|
357
|
+
clientConnected: "clientConnected",
|
|
358
|
+
/** Backend → worker: the last dashboard tab closed. Triggers the
|
|
359
|
+
* interactive shutdown flow (close WS, exit, pkill the dashboard). */
|
|
360
|
+
clientDisconnected: "clientDisconnected",
|
|
361
|
+
/** Worker → backend → clients (or app-local): wipe the visualization data
|
|
362
|
+
* for a specific test/suite uid (or the whole tree). */
|
|
363
|
+
clearExecutionData: "clearExecutionData",
|
|
364
|
+
/** Worker → backend: clear-by-test-uid request (drives clearExecutionData). */
|
|
365
|
+
clearCommands: "clearCommands",
|
|
366
|
+
/** Backend → clients: signal that the active run was stopped by the UI. */
|
|
367
|
+
testStopped: "testStopped",
|
|
368
|
+
/** Worker → backend → clients: swap an earlier captured command for an
|
|
369
|
+
* updated entry (used when a retry coalesces commands). */
|
|
370
|
+
replaceCommand: "replaceCommand",
|
|
371
|
+
/** Worker → backend: register the config file path so reruns can spawn
|
|
372
|
+
* with the same config. */
|
|
373
|
+
config: "config"
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// ../shared/src/runner.ts
|
|
377
|
+
var REUSE_ENV = {
|
|
378
|
+
REUSE: "DEVTOOLS_APP_REUSE",
|
|
379
|
+
HOST: "DEVTOOLS_APP_HOST",
|
|
380
|
+
PORT: "DEVTOOLS_APP_PORT",
|
|
381
|
+
RERUN_LABEL: "DEVTOOLS_RERUN_LABEL",
|
|
382
|
+
RERUN_ENTRY_TYPE: "DEVTOOLS_RERUN_ENTRY_TYPE"
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ../shared/src/timing.ts
|
|
386
|
+
var TIMING_BASE = {
|
|
387
|
+
/** ms to let the dashboard render between rapid state updates. */
|
|
388
|
+
UI_RENDER_DELAY: 150,
|
|
389
|
+
/** ms to wait after a test starts before flushing initial state. */
|
|
390
|
+
TEST_START_DELAY: 100,
|
|
391
|
+
/** ms gap between consecutive suite-finalize broadcasts. */
|
|
392
|
+
SUITE_COMPLETE_DELAY: 200,
|
|
393
|
+
/** ms allowed for the browser-close handshake before forcing teardown. */
|
|
394
|
+
BROWSER_CLOSE_WAIT: 2e3,
|
|
395
|
+
/** ms before first worker WS connection attempt — lets backend bind. */
|
|
396
|
+
INITIAL_CONNECTION_WAIT: 500,
|
|
397
|
+
/** ms between browser-window-alive polls. */
|
|
398
|
+
BROWSER_POLL_INTERVAL: 1e3
|
|
399
|
+
};
|
|
400
|
+
var DEFAULTS_BASE = {
|
|
401
|
+
/** Synthetic capability ID — present on every CommandLog / TestStats. */
|
|
402
|
+
CID: "0-0",
|
|
403
|
+
/** TestStats.retries default — adapters that surface retry counts override. */
|
|
404
|
+
RETRIES: 0,
|
|
405
|
+
/** _duration placeholder for not-yet-finalized stats. */
|
|
406
|
+
DURATION: 0
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// ../shared/src/types.ts
|
|
410
|
+
var TEST_STATE = {
|
|
411
|
+
PENDING: "pending",
|
|
412
|
+
RUNNING: "running",
|
|
413
|
+
PASSED: "passed",
|
|
414
|
+
FAILED: "failed",
|
|
415
|
+
SKIPPED: "skipped"
|
|
416
|
+
};
|
|
417
|
+
var SCREENCAST_DEFAULTS = {
|
|
418
|
+
enabled: false,
|
|
419
|
+
captureFormat: "jpeg",
|
|
420
|
+
quality: 70,
|
|
421
|
+
maxWidth: 1280,
|
|
422
|
+
maxHeight: 720,
|
|
423
|
+
pollIntervalMs: 200
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// ../core/src/screencast.ts
|
|
427
|
+
var ScreencastRecorderBase = class {
|
|
428
|
+
buffer = [];
|
|
429
|
+
options;
|
|
430
|
+
driver;
|
|
431
|
+
#pollTimer;
|
|
432
|
+
#isRecording = false;
|
|
433
|
+
#cdpActive = false;
|
|
434
|
+
#startIndex = 0;
|
|
435
|
+
#startMarkerSet = false;
|
|
436
|
+
constructor(options = {}) {
|
|
437
|
+
this.options = { ...SCREENCAST_DEFAULTS, ...options };
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Start recording. Tries the CDP fast-path first (if the subclass overrode
|
|
441
|
+
* `tryStartCdp`); falls back to screenshot polling otherwise. Safe to call
|
|
442
|
+
* even if the browser doesn't support screenshots — failures are logged and
|
|
443
|
+
* recording is simply skipped.
|
|
444
|
+
*/
|
|
445
|
+
async start(driver) {
|
|
446
|
+
if (this.#isRecording) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.driver = driver;
|
|
450
|
+
const cdpOk = await this.tryStartCdp();
|
|
451
|
+
if (cdpOk) {
|
|
452
|
+
this.#cdpActive = true;
|
|
453
|
+
this.#isRecording = true;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await this.#startPolling();
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Stop recording and release resources. Safe to call even if start() was
|
|
460
|
+
* never called or failed.
|
|
461
|
+
*/
|
|
462
|
+
async stop() {
|
|
463
|
+
if (!this.#isRecording) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (this.#cdpActive) {
|
|
467
|
+
await this.tryStopCdp();
|
|
468
|
+
this.#cdpActive = false;
|
|
469
|
+
} else if (this.#pollTimer !== void 0) {
|
|
470
|
+
this.#stopPolling();
|
|
471
|
+
}
|
|
472
|
+
this.#isRecording = false;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Mark the current frame position as the start of meaningful recording.
|
|
476
|
+
* Frames captured before this call (blank browser, pre-navigation pauses)
|
|
477
|
+
* are excluded from `frames`. Idempotent — only the first call takes effect.
|
|
478
|
+
*/
|
|
479
|
+
setStartMarker() {
|
|
480
|
+
if (!this.#startMarkerSet) {
|
|
481
|
+
this.#startMarkerSet = true;
|
|
482
|
+
this.#startIndex = this.buffer.length;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** Frames to encode — everything from the first meaningful action onwards. */
|
|
486
|
+
get frames() {
|
|
487
|
+
return this.buffer.slice(this.#startIndex);
|
|
488
|
+
}
|
|
489
|
+
/** Duration in ms between first and last captured frame. Zero if <2 frames. */
|
|
490
|
+
get duration() {
|
|
491
|
+
const f = this.frames;
|
|
492
|
+
if (f.length < 2) {
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
return f[f.length - 1].timestamp - f[0].timestamp;
|
|
496
|
+
}
|
|
497
|
+
get isRecording() {
|
|
498
|
+
return this.#isRecording;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Try to start CDP push-mode recording. Return `true` on success. Default
|
|
502
|
+
* returns `false` → caller falls back to polling. Subclasses that wire CDP
|
|
503
|
+
* push themselves (WDIO via Puppeteer, Selenium via createCDPConnection)
|
|
504
|
+
* override and push frames into `this.frames` directly when CDP fires.
|
|
505
|
+
*/
|
|
506
|
+
async tryStartCdp() {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
/** Stop the CDP push-mode session started by `tryStartCdp`. */
|
|
510
|
+
async tryStopCdp() {
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Helper for CDP subclasses: push a frame onto the buffer with the right
|
|
514
|
+
* timestamp normalization (CDP gives seconds-as-float; we store ms).
|
|
515
|
+
*/
|
|
516
|
+
pushCdpFrame(data, timestampSeconds) {
|
|
517
|
+
const timestamp = typeof timestampSeconds === "number" ? Math.round(timestampSeconds * 1e3) : Date.now();
|
|
518
|
+
this.buffer.push({ data, timestamp });
|
|
519
|
+
}
|
|
520
|
+
/** Whether `setStartMarker` (or `markStartAtLatest`) has fired yet. */
|
|
521
|
+
get hasStartMarker() {
|
|
522
|
+
return this.#startMarkerSet;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Anchor the start marker to the most recently pushed frame. Used by
|
|
526
|
+
* subclasses that detect the first content-bearing frame heuristically
|
|
527
|
+
* (e.g. selenium's blank-frame-byte-size threshold) and want to skip the
|
|
528
|
+
* preceding about:blank dead-air without waiting for an explicit caller.
|
|
529
|
+
*/
|
|
530
|
+
markStartAtLatest() {
|
|
531
|
+
if (!this.#startMarkerSet) {
|
|
532
|
+
this.#startMarkerSet = true;
|
|
533
|
+
this.#startIndex = Math.max(0, this.buffer.length - 1);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// ─── Polling implementation ─────────────────────────────────────────────
|
|
537
|
+
/**
|
|
538
|
+
* Hook fired when the polling loop starts. Default: no-op. Subclasses
|
|
539
|
+
* (adapters with their own logger) override to surface visibility.
|
|
540
|
+
*/
|
|
541
|
+
onPollingStarted(_intervalMs) {
|
|
542
|
+
}
|
|
543
|
+
/** Hook fired when polling stops cleanly (driver still alive at the time). */
|
|
544
|
+
onPollingStopped(_frameCount) {
|
|
545
|
+
}
|
|
546
|
+
/** Hook fired when the polling fallback couldn't even take the first shot. */
|
|
547
|
+
onUnavailable(_err) {
|
|
548
|
+
}
|
|
549
|
+
// ─── Polling implementation ─────────────────────────────────────────────
|
|
550
|
+
async #startPolling() {
|
|
551
|
+
try {
|
|
552
|
+
const first = await this.takeScreenshot();
|
|
553
|
+
if (first === null) {
|
|
554
|
+
this.onUnavailable(new Error("first screenshot returned null"));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.buffer.push({ data: first, timestamp: Date.now() });
|
|
558
|
+
const intervalMs = this.options.pollIntervalMs;
|
|
559
|
+
this.#pollTimer = setInterval(async () => {
|
|
89
560
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
log.info(` DevTools UI: ${url}`);
|
|
97
|
-
try {
|
|
98
|
-
// Create unique user data directory for this instance to prevent conflicts
|
|
99
|
-
this.#userDataDir = path.join(os.tmpdir(), `nightwatch-devtools-${this.options.port}-${Date.now()}`);
|
|
100
|
-
if (!fs.existsSync(this.#userDataDir)) {
|
|
101
|
-
fs.mkdirSync(this.#userDataDir, { recursive: true });
|
|
102
|
-
}
|
|
103
|
-
this.#devtoolsBrowser = await remote({
|
|
104
|
-
logLevel: 'info',
|
|
105
|
-
automationProtocol: 'devtools',
|
|
106
|
-
capabilities: {
|
|
107
|
-
browserName: 'chrome',
|
|
108
|
-
'goog:chromeOptions': {
|
|
109
|
-
args: [
|
|
110
|
-
'--window-size=1600,1200',
|
|
111
|
-
`--user-data-dir=${this.#userDataDir}`,
|
|
112
|
-
'--no-first-run',
|
|
113
|
-
'--no-default-browser-check'
|
|
114
|
-
]
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
await this.#devtoolsBrowser.url(url);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
log.error(`Failed to open DevTools UI: ${err.message}`);
|
|
122
|
-
log.info(`Please manually open: ${url}`);
|
|
123
|
-
}
|
|
124
|
-
await new Promise((resolve) => setTimeout(resolve, TIMING.UI_CONNECTION_WAIT));
|
|
125
|
-
globalThis[PLUGIN_GLOBAL_KEY] = this;
|
|
561
|
+
const data = await this.takeScreenshot();
|
|
562
|
+
if (data !== null) {
|
|
563
|
+
this.buffer.push({ data, timestamp: Date.now() });
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
this.#stopPolling();
|
|
126
567
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
568
|
+
}, intervalMs);
|
|
569
|
+
this.#isRecording = true;
|
|
570
|
+
this.onPollingStarted(intervalMs);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
this.onUnavailable(err);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
#stopPolling() {
|
|
576
|
+
if (this.#pollTimer !== void 0) {
|
|
577
|
+
clearInterval(this.#pollTimer);
|
|
578
|
+
this.#pollTimer = void 0;
|
|
579
|
+
this.onPollingStopped(this.buffer.length);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// ../core/src/script-loader.ts
|
|
585
|
+
import fs4 from "fs/promises";
|
|
586
|
+
import path4 from "path";
|
|
587
|
+
import { createRequire as createRequire2 } from "module";
|
|
588
|
+
var require3 = createRequire2(import.meta.url);
|
|
589
|
+
async function loadInjectableScript() {
|
|
590
|
+
const scriptPath = require3.resolve("@wdio/devtools-script");
|
|
591
|
+
const scriptDir = path4.dirname(scriptPath);
|
|
592
|
+
const preloadScriptPath = path4.join(scriptDir, "script.js");
|
|
593
|
+
const scriptContent = await fs4.readFile(preloadScriptPath, "utf-8");
|
|
594
|
+
return `(async function() { ${scriptContent} })()`;
|
|
595
|
+
}
|
|
596
|
+
async function pollUntilReady(check, opts = {}) {
|
|
597
|
+
const attempts = opts.attempts ?? 5;
|
|
598
|
+
const intervalMs = opts.intervalMs ?? 200;
|
|
599
|
+
for (let i = 0; i < attempts; i++) {
|
|
600
|
+
await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
|
|
601
|
+
if (await check()) {
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ../core/src/session-capturer.ts
|
|
609
|
+
import fs5 from "fs/promises";
|
|
610
|
+
import { WebSocket } from "ws";
|
|
611
|
+
var SessionCapturerBase = class {
|
|
612
|
+
// ── State (mostly private; subclasses access shared ws via `this.ws`) ────
|
|
613
|
+
/**
|
|
614
|
+
* Exposed as `protected` so subclasses with framework-specific close/wait
|
|
615
|
+
* semantics (e.g. nightwatch's `closeWebSocket` with timeout) can operate
|
|
616
|
+
* on the socket directly. Default lifecycle is fully managed by the base.
|
|
617
|
+
*/
|
|
618
|
+
ws;
|
|
619
|
+
#hasConnected = false;
|
|
620
|
+
#originalConsoleMethods;
|
|
621
|
+
#originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
622
|
+
#originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
623
|
+
// Two flags (not one): prevents re-entrant capture when console.* writes to
|
|
624
|
+
// stdout, OR when stream forwarding wants to log via console.
|
|
625
|
+
#isCapturingConsole = false;
|
|
626
|
+
#isCapturingStream = false;
|
|
627
|
+
// Command bookkeeping — used by adapters that emit commands themselves
|
|
628
|
+
// (nightwatch, selenium). The WDIO service adapter doesn't call sendCommand
|
|
629
|
+
// (WDIO owns the command lifecycle), so this state is harmless overhead.
|
|
630
|
+
// `protected` (not `#`) so subclasses can override the send/replace flow
|
|
631
|
+
// while still sharing the counter and de-dup set with base helpers.
|
|
632
|
+
commandCounter = 0;
|
|
633
|
+
sentCommandIds = /* @__PURE__ */ new Set();
|
|
634
|
+
// Map of file path → source text. Populated by `captureSource` (also
|
|
635
|
+
// accessed by adapter-specific source-discovery flows, e.g. service's
|
|
636
|
+
// `ensureSourceLoaded` which parses `file://` locations first).
|
|
637
|
+
sources = /* @__PURE__ */ new Map();
|
|
638
|
+
// Captured trace payload — populated by `processTracePayload` (driven from
|
|
639
|
+
// adapter-specific `captureTrace` flows) and by direct pushes from BiDi/CDP
|
|
640
|
+
// listeners. Mutations stay `unknown[]` here because the canonical
|
|
641
|
+
// `TraceMutation` shape is a browser-only DOM type (script package); cross-
|
|
642
|
+
// package consumers treat the array as opaque.
|
|
643
|
+
commandsLog = [];
|
|
644
|
+
consoleLogs = [];
|
|
645
|
+
networkRequests = [];
|
|
646
|
+
mutations = [];
|
|
647
|
+
traceLogs = [];
|
|
648
|
+
metadata;
|
|
649
|
+
// ── Construction ────────────────────────────────────────────────────────
|
|
650
|
+
constructor(opts = {}) {
|
|
651
|
+
const { hostname, port } = opts;
|
|
652
|
+
if (hostname && port) {
|
|
653
|
+
this.ws = new WebSocket(`ws://${hostname}:${port}${WS_PATHS.worker}`);
|
|
654
|
+
this.ws.on("open", () => {
|
|
655
|
+
this.#hasConnected = true;
|
|
656
|
+
this.onWsOpen();
|
|
657
|
+
});
|
|
658
|
+
this.ws.on("error", (err) => this.onWsError(err));
|
|
659
|
+
this.ws.on("close", () => this.onWsClose());
|
|
660
|
+
this.ws.on("message", (raw) => {
|
|
661
|
+
try {
|
|
662
|
+
const parsed = JSON.parse(raw.toString());
|
|
663
|
+
this.onWsMessage(parsed);
|
|
664
|
+
} catch {
|
|
130
665
|
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
this.#originalConsoleMethods = {
|
|
669
|
+
log: console.log,
|
|
670
|
+
info: console.info,
|
|
671
|
+
warn: console.warn,
|
|
672
|
+
error: console.error
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
676
|
+
/**
|
|
677
|
+
* Send a typed event to the dashboard. No-op if the WS isn't open. Catches
|
|
678
|
+
* send-time exceptions so a transient socket error never aborts the host
|
|
679
|
+
* runner. Subclasses that want diagnostics on drop or error override
|
|
680
|
+
* {@link onUpstreamDrop}.
|
|
681
|
+
*/
|
|
682
|
+
sendUpstream(event, data) {
|
|
683
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
684
|
+
this.onUpstreamDrop(event, "closed");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
this.ws.send(JSON.stringify({ scope: event, data }));
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.onUpstreamDrop(event, "send-error", err);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Hook fired when a {@link sendUpstream} call can't deliver. Default: silent
|
|
695
|
+
* (matches the historical behavior of service/selenium). Nightwatch overrides
|
|
696
|
+
* this to log a warning — useful when a runner drops mid-test and the user
|
|
697
|
+
* needs to know why captured data is incomplete.
|
|
698
|
+
*/
|
|
699
|
+
onUpstreamDrop(_event, _reason, _err) {
|
|
700
|
+
}
|
|
701
|
+
/** True once the WS has opened at least once and is currently OPEN. */
|
|
702
|
+
isConnected() {
|
|
703
|
+
return Boolean(this.ws) && this.ws?.readyState === WebSocket.OPEN;
|
|
704
|
+
}
|
|
705
|
+
/** Property-style alias for {@link isConnected} — used by tests that
|
|
706
|
+
* read it as a getter while mutating `ws.readyState` directly. */
|
|
707
|
+
get isReportingUpstream() {
|
|
708
|
+
return this.isConnected();
|
|
709
|
+
}
|
|
710
|
+
/** Subclasses can read this to gate retry/reconnect logic. */
|
|
711
|
+
hasEverConnected() {
|
|
712
|
+
return this.#hasConnected;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Send a CommandLog over the WS. If the entry already has an `_id` (set by
|
|
716
|
+
* the adapter's `captureCommand` during buffering), use it; otherwise
|
|
717
|
+
* allocate a fresh one. The `_id` is the de-dup key and is stripped from
|
|
718
|
+
* the broadcast payload — it's adapter-internal bookkeeping.
|
|
719
|
+
* Returns the id, or 0 if the entry had no `_id` and none could be assigned.
|
|
720
|
+
*/
|
|
721
|
+
sendCommand(command) {
|
|
722
|
+
if (command._id === void 0) {
|
|
723
|
+
command._id = this.commandCounter++;
|
|
724
|
+
}
|
|
725
|
+
const id = command._id;
|
|
726
|
+
if (this.sentCommandIds.has(id)) {
|
|
727
|
+
return id;
|
|
728
|
+
}
|
|
729
|
+
this.sentCommandIds.add(id);
|
|
730
|
+
const toSend = { ...command };
|
|
731
|
+
delete toSend._id;
|
|
732
|
+
this.sendUpstream("commands", [toSend]);
|
|
733
|
+
return id;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Emit a `replaceCommand` event swapping an earlier entry in-place. Strips
|
|
737
|
+
* the adapter-internal `_id` field before sending — that's bookkeeping for
|
|
738
|
+
* the local `sentCommandIds` set and shouldn't reach the UI.
|
|
739
|
+
*/
|
|
740
|
+
sendReplaceCommand(oldTimestamp, command) {
|
|
741
|
+
const toSend = { ...command };
|
|
742
|
+
delete toSend._id;
|
|
743
|
+
this.sendUpstream(WS_SCOPE.replaceCommand, {
|
|
744
|
+
oldTimestamp,
|
|
745
|
+
command: toSend
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Read a file from disk, store in `sources`, and broadcast to the UI via
|
|
750
|
+
* `sendUpstream('sources', { [path]: text })`. Idempotent — a cached path is
|
|
751
|
+
* a no-op. Read errors are logged via `onSourceReadError` (default: silent)
|
|
752
|
+
* so a missing source never aborts capture.
|
|
753
|
+
*/
|
|
754
|
+
async captureSource(filePath) {
|
|
755
|
+
if (this.sources.has(filePath)) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const source = (await fs5.readFile(filePath, "utf-8")).toString();
|
|
760
|
+
this.sources.set(filePath, source);
|
|
761
|
+
this.sendUpstream("sources", { [filePath]: source });
|
|
762
|
+
} catch (err) {
|
|
763
|
+
this.onSourceReadError(filePath, err);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Hook fired when `captureSource` can't read a file. Default: silent.
|
|
768
|
+
* Subclasses (nightwatch, selenium) override to log a warning.
|
|
769
|
+
*/
|
|
770
|
+
onSourceReadError(_filePath, _err) {
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Ingest the `{ mutations, traceLogs, consoleLogs, networkRequests, metadata }`
|
|
774
|
+
* payload returned by the page-side `wdioTraceCollector.getTraceData()`.
|
|
775
|
+
* Tags console logs with `source: 'browser'`, pushes each array into the
|
|
776
|
+
* matching local field, and broadcasts via the appropriate WS scopes.
|
|
777
|
+
*
|
|
778
|
+
* `skipConsoleLogs` / `skipNetworkRequests` opt out when an out-of-band
|
|
779
|
+
* channel (BiDi) is already delivering those streams — without the gate
|
|
780
|
+
* the dashboard would see each entry twice.
|
|
781
|
+
*/
|
|
782
|
+
processTracePayload(payload, opts = {}) {
|
|
783
|
+
const { mutations, traceLogs, consoleLogs, networkRequests, metadata } = payload;
|
|
784
|
+
if (metadata && typeof metadata === "object") {
|
|
785
|
+
this.metadata = {
|
|
786
|
+
...this.metadata,
|
|
787
|
+
...metadata
|
|
788
|
+
};
|
|
789
|
+
this.sendUpstream("metadata", this.metadata);
|
|
790
|
+
}
|
|
791
|
+
if (!opts.skipConsoleLogs && Array.isArray(consoleLogs) && consoleLogs.length > 0) {
|
|
792
|
+
const tagged = consoleLogs.map((entry) => ({
|
|
793
|
+
...entry,
|
|
794
|
+
source: LOG_SOURCES.BROWSER
|
|
795
|
+
}));
|
|
796
|
+
this.consoleLogs.push(...tagged);
|
|
797
|
+
this.sendUpstream("consoleLogs", tagged);
|
|
798
|
+
}
|
|
799
|
+
if (!opts.skipNetworkRequests && Array.isArray(networkRequests) && networkRequests.length > 0) {
|
|
800
|
+
const reqs = networkRequests;
|
|
801
|
+
this.networkRequests.push(...reqs);
|
|
802
|
+
this.sendUpstream("networkRequests", reqs);
|
|
131
803
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
804
|
+
if (Array.isArray(mutations) && mutations.length > 0) {
|
|
805
|
+
this.mutations.push(...mutations);
|
|
806
|
+
this.sendUpstream("mutations", mutations);
|
|
807
|
+
}
|
|
808
|
+
if (Array.isArray(traceLogs) && traceLogs.length > 0) {
|
|
809
|
+
const logs = traceLogs;
|
|
810
|
+
this.traceLogs.push(...logs);
|
|
811
|
+
this.sendUpstream("logs", logs);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Resolve when the WS reaches OPEN state, or `false` on timeout / error.
|
|
816
|
+
* Returns immediately if already open. Used by adapters that need a
|
|
817
|
+
* synchronization barrier before injecting page-side scripts.
|
|
818
|
+
*/
|
|
819
|
+
async waitForConnection(timeoutMs = 5e3) {
|
|
820
|
+
if (!this.ws) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
return new Promise((resolve2) => {
|
|
827
|
+
const timeout = setTimeout(() => resolve2(false), timeoutMs);
|
|
828
|
+
this.ws.once("open", () => {
|
|
829
|
+
clearTimeout(timeout);
|
|
830
|
+
resolve2(true);
|
|
831
|
+
});
|
|
832
|
+
this.ws.once("error", () => {
|
|
833
|
+
clearTimeout(timeout);
|
|
834
|
+
resolve2(false);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Gracefully close the WS, waiting up to 2s for buffered messages to flush.
|
|
840
|
+
* Call before process exit in reuse mode (or after dashboard close) so the
|
|
841
|
+
* backend sees a clean close instead of an abrupt TCP reset.
|
|
842
|
+
*/
|
|
843
|
+
async closeWebSocket() {
|
|
844
|
+
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
return new Promise((resolve2) => {
|
|
848
|
+
const timeout = setTimeout(resolve2, 2e3);
|
|
849
|
+
this.ws.once("close", () => {
|
|
850
|
+
clearTimeout(timeout);
|
|
851
|
+
resolve2();
|
|
852
|
+
});
|
|
853
|
+
this.ws.close();
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Restore console/streams. Does NOT close the WS — that's the subclass's
|
|
858
|
+
* call (see `closeWebSocket` on nightwatch/selenium). Closing here would
|
|
859
|
+
* break the wait-for-dashboard-close flow, since the worker WS is the
|
|
860
|
+
* channel the backend uses to signal `clientDisconnected`.
|
|
861
|
+
*/
|
|
862
|
+
cleanup() {
|
|
863
|
+
this.restoreConsole();
|
|
864
|
+
this.restoreStreams();
|
|
865
|
+
}
|
|
866
|
+
// ── Patching (call from subclass constructor) ───────────────────────────
|
|
867
|
+
/** Patch `console.log/info/warn/error` to forward through `onLine`. */
|
|
868
|
+
patchConsole() {
|
|
869
|
+
CONSOLE_METHODS.forEach((method) => {
|
|
870
|
+
const original = this.#originalConsoleMethods[method];
|
|
871
|
+
console[method] = (...args) => {
|
|
872
|
+
this.#isCapturingConsole = true;
|
|
873
|
+
const result = original.apply(console, args);
|
|
874
|
+
this.#isCapturingConsole = false;
|
|
875
|
+
const serialized = args.map(
|
|
876
|
+
(a) => typeof a === "object" && a !== null ? safeStringify(a) : String(a)
|
|
877
|
+
);
|
|
878
|
+
const joined = stripAnsi(serialized.join(" ")).trim();
|
|
879
|
+
if (!joined || this.isInternalStreamLine(joined)) {
|
|
880
|
+
return result;
|
|
142
881
|
}
|
|
143
|
-
this
|
|
144
|
-
|
|
145
|
-
|
|
882
|
+
this.onLine(method, serialized, LOG_SOURCES.TEST);
|
|
883
|
+
return result;
|
|
884
|
+
};
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Wrap `process.stdout.write` and `process.stderr.write` to forward chunks
|
|
889
|
+
* through `onLine`. The base's `#isCapturingConsole` flag prevents
|
|
890
|
+
* re-entrance when console patching itself writes to stdout.
|
|
891
|
+
*/
|
|
892
|
+
patchStreams() {
|
|
893
|
+
const captureChunk = (raw) => {
|
|
894
|
+
if (this.#isCapturingStream) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const text = typeof raw === "string" ? raw : raw.toString();
|
|
898
|
+
if (!text?.trim()) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
this.#isCapturingStream = true;
|
|
902
|
+
try {
|
|
903
|
+
for (const rawLine of text.split("\n")) {
|
|
904
|
+
const segments = rawLine.split("\r").filter((s) => s.trim());
|
|
905
|
+
const lastSegment = segments[segments.length - 1] ?? rawLine;
|
|
906
|
+
const clean = stripAnsi(lastSegment).trim();
|
|
907
|
+
if (!clean || this.isInternalStreamLine(clean) || SPINNER_RE.test(clean)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
this.onLine(detectLogLevel(clean), [clean], LOG_SOURCES.TERMINAL);
|
|
146
911
|
}
|
|
147
|
-
|
|
148
|
-
this
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
912
|
+
} finally {
|
|
913
|
+
this.#isCapturingStream = false;
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
const wrap = (stream, original) => {
|
|
917
|
+
const capturer = this;
|
|
918
|
+
stream.write = function(chunk, ...rest) {
|
|
919
|
+
const writeAny = original;
|
|
920
|
+
const result = writeAny.call(stream, chunk, ...rest);
|
|
921
|
+
if (chunk && !capturer.#isCapturingConsole) {
|
|
922
|
+
captureChunk(chunk);
|
|
152
923
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
924
|
+
return result;
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
wrap(process.stdout, this.#originalStdoutWrite);
|
|
928
|
+
wrap(process.stderr, this.#originalStderrWrite);
|
|
929
|
+
}
|
|
930
|
+
restoreConsole() {
|
|
931
|
+
CONSOLE_METHODS.forEach((method) => {
|
|
932
|
+
console[method] = this.#originalConsoleMethods[method];
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
restoreStreams() {
|
|
936
|
+
process.stdout.write = this.#originalStdoutWrite;
|
|
937
|
+
process.stderr.write = this.#originalStderrWrite;
|
|
938
|
+
}
|
|
939
|
+
// ── Hooks (subclasses override) ─────────────────────────────────────────
|
|
940
|
+
/**
|
|
941
|
+
* Default: forward a single ConsoleLog via the `consoleLogs` scope.
|
|
942
|
+
* Args is passed as an array (matching the original console.* call shape:
|
|
943
|
+
* `console.log('a', 'b')` → `args = ['a', 'b']`) so subclasses can preserve
|
|
944
|
+
* the multi-argument structure for the UI.
|
|
945
|
+
*
|
|
946
|
+
* Subclasses that need to maintain local capture state (for the rerun/
|
|
947
|
+
* replay flow) should override to also push the entry into their own
|
|
948
|
+
* array — see service's onLine override.
|
|
949
|
+
*/
|
|
950
|
+
onLine(type, args, source) {
|
|
951
|
+
const entry = createConsoleLogEntry(type, args, source);
|
|
952
|
+
this.sendUpstream("consoleLogs", [entry]);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Default delegates to {@link isInternalStreamLine} from `./console.js`.
|
|
956
|
+
* Subclasses can override to add framework-specific filters.
|
|
957
|
+
*/
|
|
958
|
+
isInternalStreamLine(line) {
|
|
959
|
+
return isInternalStreamLine(line);
|
|
960
|
+
}
|
|
961
|
+
/** Hook: WS opened. Subclasses override to send a handshake, etc. */
|
|
962
|
+
onWsOpen() {
|
|
963
|
+
}
|
|
964
|
+
/** Hook: WS errored before opening (likely no backend listening). */
|
|
965
|
+
onWsError(_err) {
|
|
966
|
+
}
|
|
967
|
+
/** Hook: WS closed (after open, or as a result of cleanup). */
|
|
968
|
+
onWsClose() {
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Hook: WS message received from the backend. Currently used by selenium's
|
|
972
|
+
* `awaitClientConnected` to know when a dashboard tab has subscribed.
|
|
973
|
+
*/
|
|
974
|
+
onWsMessage(_msg) {
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
function safeStringify(value) {
|
|
978
|
+
try {
|
|
979
|
+
return JSON.stringify(value);
|
|
980
|
+
} catch {
|
|
981
|
+
return String(value);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ../core/src/suite-helpers.ts
|
|
986
|
+
function createSuiteStats(init) {
|
|
987
|
+
return {
|
|
988
|
+
uid: init.uid,
|
|
989
|
+
cid: init.cid ?? "0-0",
|
|
990
|
+
title: init.title,
|
|
991
|
+
fullTitle: init.title,
|
|
992
|
+
file: init.file,
|
|
993
|
+
type: "suite",
|
|
994
|
+
start: init.start ?? /* @__PURE__ */ new Date(),
|
|
995
|
+
state: init.state ?? TEST_STATE.RUNNING,
|
|
996
|
+
end: null,
|
|
997
|
+
tests: [],
|
|
998
|
+
suites: [],
|
|
999
|
+
hooks: [],
|
|
1000
|
+
_duration: 0,
|
|
1001
|
+
callSource: init.callSource,
|
|
1002
|
+
featureFile: init.featureFile,
|
|
1003
|
+
parent: init.parent
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function createTestStats(init) {
|
|
1007
|
+
return {
|
|
1008
|
+
uid: init.uid,
|
|
1009
|
+
cid: init.cid ?? "0-0",
|
|
1010
|
+
title: init.title,
|
|
1011
|
+
fullTitle: init.fullTitle ?? init.title,
|
|
1012
|
+
parent: init.parent,
|
|
1013
|
+
state: init.state ?? TEST_STATE.RUNNING,
|
|
1014
|
+
start: init.start ?? /* @__PURE__ */ new Date(),
|
|
1015
|
+
end: null,
|
|
1016
|
+
type: "test",
|
|
1017
|
+
file: init.file,
|
|
1018
|
+
retries: 0,
|
|
1019
|
+
_duration: 0,
|
|
1020
|
+
hooks: [],
|
|
1021
|
+
callSource: init.callSource
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function computeSuiteFinalStateStrict(suite) {
|
|
1025
|
+
const tests = suite.tests;
|
|
1026
|
+
const suites = suite.suites ?? [];
|
|
1027
|
+
const hasFailures = tests.some((t) => t.state === TEST_STATE.FAILED) || suites.some((s) => s.state === TEST_STATE.FAILED);
|
|
1028
|
+
if (hasFailures) {
|
|
1029
|
+
return TEST_STATE.FAILED;
|
|
1030
|
+
}
|
|
1031
|
+
const allPassed = tests.every(
|
|
1032
|
+
(t) => t.state === TEST_STATE.PASSED || t.state === TEST_STATE.SKIPPED
|
|
1033
|
+
) && suites.every(
|
|
1034
|
+
(s) => s.state === TEST_STATE.PASSED || s.state === TEST_STATE.SKIPPED
|
|
1035
|
+
);
|
|
1036
|
+
const hasSkipped = tests.some((t) => t.state === TEST_STATE.SKIPPED);
|
|
1037
|
+
const hasItems = tests.length > 0 || suites.length > 0;
|
|
1038
|
+
if (!hasItems || allPassed) {
|
|
1039
|
+
return TEST_STATE.PASSED;
|
|
1040
|
+
}
|
|
1041
|
+
if (hasSkipped) {
|
|
1042
|
+
return TEST_STATE.PASSED;
|
|
1043
|
+
}
|
|
1044
|
+
return TEST_STATE.FAILED;
|
|
1045
|
+
}
|
|
1046
|
+
function computeSuiteRunningState(suite) {
|
|
1047
|
+
const tests = suite.tests;
|
|
1048
|
+
const suites = suite.suites ?? [];
|
|
1049
|
+
const hasFailures = tests.some((t) => t.state === TEST_STATE.FAILED) || suites.some((s) => s.state === TEST_STATE.FAILED);
|
|
1050
|
+
if (hasFailures) {
|
|
1051
|
+
return TEST_STATE.FAILED;
|
|
1052
|
+
}
|
|
1053
|
+
const hasRunning = tests.some((t) => t.state === TEST_STATE.RUNNING) || suites.some((s) => s.state === TEST_STATE.RUNNING || !s.end);
|
|
1054
|
+
return hasRunning ? TEST_STATE.RUNNING : TEST_STATE.PASSED;
|
|
1055
|
+
}
|
|
1056
|
+
function stampSuiteEnd(suite, end = /* @__PURE__ */ new Date()) {
|
|
1057
|
+
suite.end = end;
|
|
1058
|
+
suite._duration = end.getTime() - (suite.start?.getTime() || end.getTime());
|
|
1059
|
+
}
|
|
1060
|
+
function stampTestEnd(test, end = /* @__PURE__ */ new Date()) {
|
|
1061
|
+
test.end = end;
|
|
1062
|
+
test._duration = end.getTime() - (test.start?.getTime() ?? end.getTime());
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ../core/src/test-discovery.ts
|
|
1066
|
+
import fs6 from "fs";
|
|
1067
|
+
function findTestDefinitions(filePath, opts = {}) {
|
|
1068
|
+
if (!fs6.existsSync(filePath)) {
|
|
1069
|
+
return [];
|
|
1070
|
+
}
|
|
1071
|
+
let source;
|
|
1072
|
+
try {
|
|
1073
|
+
source = fs6.readFileSync(filePath, "utf-8");
|
|
1074
|
+
} catch {
|
|
1075
|
+
return [];
|
|
1076
|
+
}
|
|
1077
|
+
const out = [];
|
|
1078
|
+
const lines = source.split("\n");
|
|
1079
|
+
const suiteRe = /\b(?:describe|suite|context)\s*\(\s*['"`]([^'"`]+)['"`]/;
|
|
1080
|
+
const testRe = /\b(?:it|test|specify)\s*\(\s*['"`]([^'"`]+)['"`]/;
|
|
1081
|
+
const objRe = /^\s*['"`]([^'"`]+)['"`]\s*:\s*(?:async\s+)?(?:\([^)]*\)\s*=>|function\s*\()/;
|
|
1082
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1083
|
+
const line = lines[i];
|
|
1084
|
+
const lineNum = i + 1;
|
|
1085
|
+
const suiteMatch = line.match(suiteRe);
|
|
1086
|
+
if (suiteMatch) {
|
|
1087
|
+
out.push({ kind: "suite", title: suiteMatch[1], line: lineNum });
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const testMatch = line.match(testRe);
|
|
1091
|
+
if (testMatch) {
|
|
1092
|
+
out.push({ kind: "test", title: testMatch[1], line: lineNum });
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (opts.includeNightwatchObjectStyle) {
|
|
1096
|
+
const objMatch = line.match(objRe);
|
|
1097
|
+
if (objMatch) {
|
|
1098
|
+
out.push({ kind: "test", title: objMatch[1], line: lineNum });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return out;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ../core/src/test-reporter.ts
|
|
1106
|
+
var TestReporterBase = class {
|
|
1107
|
+
#report;
|
|
1108
|
+
allSuites = [];
|
|
1109
|
+
constructor(report) {
|
|
1110
|
+
this.#report = report;
|
|
1111
|
+
resetSignatureCounters();
|
|
1112
|
+
}
|
|
1113
|
+
/** Swap the upstream sink, e.g. after a WS reconnect. */
|
|
1114
|
+
updateUpstream(report) {
|
|
1115
|
+
this.#report = report;
|
|
1116
|
+
}
|
|
1117
|
+
/** Manually trigger a flush of current state to the UI. */
|
|
1118
|
+
updateSuites() {
|
|
1119
|
+
this.sendUpstream();
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Reset collected state. Subclasses with extra state (test-name cache,
|
|
1123
|
+
* current-suite ref) override and call `super.clearExecutionData()` first.
|
|
1124
|
+
*/
|
|
1125
|
+
clearExecutionData() {
|
|
1126
|
+
this.allSuites = [];
|
|
1127
|
+
resetSignatureCounters();
|
|
1128
|
+
}
|
|
1129
|
+
/** Default: find by `uid`, replace in place. */
|
|
1130
|
+
onTestEnd(test) {
|
|
1131
|
+
for (const suite of this.allSuites) {
|
|
1132
|
+
const idx = suite.tests.findIndex(
|
|
1133
|
+
(t) => typeof t !== "string" && t.uid === test.uid
|
|
1134
|
+
);
|
|
1135
|
+
if (idx !== -1) {
|
|
1136
|
+
suite.tests[idx] = test;
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
this.sendUpstream();
|
|
1141
|
+
}
|
|
1142
|
+
/** Default: just flush. Subclasses with skipped-test synthesis override. */
|
|
1143
|
+
onSuiteEnd(_suite) {
|
|
1144
|
+
this.sendUpstream();
|
|
1145
|
+
}
|
|
1146
|
+
get report() {
|
|
1147
|
+
return this.allSuites;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Flush current suite state to the upstream callback. Empty-payload guard
|
|
1151
|
+
* matches the existing adapter behavior — UI shouldn't receive an empty
|
|
1152
|
+
* array.
|
|
1153
|
+
*/
|
|
1154
|
+
sendUpstream() {
|
|
1155
|
+
const payload = [];
|
|
1156
|
+
for (const suite of this.allSuites) {
|
|
1157
|
+
if (suite.uid) {
|
|
1158
|
+
payload.push({ [suite.uid]: suite });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (payload.length > 0) {
|
|
1162
|
+
this.#report(payload);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// src/index.ts
|
|
1168
|
+
import logger11 from "@wdio/logger";
|
|
1169
|
+
|
|
1170
|
+
// src/run-lifecycle.ts
|
|
1171
|
+
import * as fs8 from "fs";
|
|
1172
|
+
import * as path6 from "path";
|
|
1173
|
+
import * as os3 from "os";
|
|
1174
|
+
import logger from "@wdio/logger";
|
|
1175
|
+
import { remote } from "webdriverio";
|
|
1176
|
+
import { start, stop } from "@wdio/devtools-backend";
|
|
1177
|
+
|
|
1178
|
+
// src/constants.ts
|
|
1179
|
+
var INTERNAL_COMMANDS_TO_IGNORE = [
|
|
1180
|
+
"isAppiumClient",
|
|
1181
|
+
"isSafari",
|
|
1182
|
+
"isChrome",
|
|
1183
|
+
"isFirefox",
|
|
1184
|
+
"isEdge",
|
|
1185
|
+
"isMobile",
|
|
1186
|
+
"isAndroid",
|
|
1187
|
+
"isIOS",
|
|
1188
|
+
"session",
|
|
1189
|
+
"sessions",
|
|
1190
|
+
"timeouts",
|
|
1191
|
+
"timeoutsAsyncScript",
|
|
1192
|
+
"timeoutsImplicitWait",
|
|
1193
|
+
"getLog",
|
|
1194
|
+
"getLogTypes",
|
|
1195
|
+
"screenshot",
|
|
1196
|
+
"availableContexts",
|
|
1197
|
+
"currentContext",
|
|
1198
|
+
"setChromeOptions",
|
|
1199
|
+
"setDeviceName",
|
|
1200
|
+
"perform",
|
|
1201
|
+
"execute",
|
|
1202
|
+
"executeAsync",
|
|
1203
|
+
"executeScript",
|
|
1204
|
+
// Internal Nightwatch transport commands (used for log capture, not user actions)
|
|
1205
|
+
"sessionLog",
|
|
1206
|
+
"sessionLogTypes",
|
|
1207
|
+
"isLogAvailable",
|
|
1208
|
+
"end"
|
|
1209
|
+
];
|
|
1210
|
+
var DEFAULTS = {
|
|
1211
|
+
...DEFAULTS_BASE,
|
|
1212
|
+
TEST_NAME: "unknown",
|
|
1213
|
+
FILE_NAME: "unknown"
|
|
1214
|
+
};
|
|
1215
|
+
var TIMING = {
|
|
1216
|
+
...TIMING_BASE,
|
|
1217
|
+
/** Nightwatch boots slower than selenium — give the dashboard 10s. */
|
|
1218
|
+
UI_CONNECTION_WAIT: 1e4
|
|
1219
|
+
};
|
|
1220
|
+
var BOOLEAN_COMMAND_PATTERN = /^waitFor|^is[A-Z]|^has[A-Z]|(Visible|Present|Enabled|Selected|NotVisible|NotPresent)$/;
|
|
1221
|
+
var NAVIGATION_COMMANDS = ["url", "navigate", "navigateTo"];
|
|
1222
|
+
var CONFIG_FILENAMES = [
|
|
1223
|
+
"nightwatch.conf.cjs",
|
|
1224
|
+
"nightwatch.conf.js",
|
|
1225
|
+
"nightwatch.conf.ts",
|
|
1226
|
+
"nightwatch.conf.mjs",
|
|
1227
|
+
"nightwatch.json"
|
|
1228
|
+
];
|
|
1229
|
+
var PLUGIN_GLOBAL_KEY = "__nightwatchDevtoolsPlugin";
|
|
1230
|
+
|
|
1231
|
+
// src/helpers/utils.ts
|
|
1232
|
+
import * as fs7 from "fs";
|
|
1233
|
+
import * as path5 from "path";
|
|
1234
|
+
import { parse as parseStackTrace2 } from "stacktrace-parser";
|
|
1235
|
+
function determineTestState(testcase) {
|
|
1236
|
+
if (testcase.passed === 0 && testcase.failed === 0) {
|
|
1237
|
+
return "skipped";
|
|
1238
|
+
}
|
|
1239
|
+
return testcase.passed > 0 && testcase.failed === 0 ? "passed" : "failed";
|
|
1240
|
+
}
|
|
1241
|
+
function getTestIcon(state) {
|
|
1242
|
+
return state === "passed" ? "\u2705" : state === "skipped" ? "\u23ED" : "\u274C";
|
|
1243
|
+
}
|
|
1244
|
+
function incrementCounters(counters, state) {
|
|
1245
|
+
if (state === "passed") {
|
|
1246
|
+
counters.passCount++;
|
|
1247
|
+
} else if (state === "skipped") {
|
|
1248
|
+
counters.skipCount++;
|
|
1249
|
+
} else {
|
|
1250
|
+
counters.failCount++;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
function buildPluginMetadataOptions(input) {
|
|
1254
|
+
return {
|
|
1255
|
+
framework: input.isCucumberRunner ? "nightwatch-cucumber" : "nightwatch",
|
|
1256
|
+
configFile: input.configPath,
|
|
1257
|
+
baseDir: process.cwd(),
|
|
1258
|
+
runCapabilities: {
|
|
1259
|
+
canRunSuites: true,
|
|
1260
|
+
canRunTests: !input.isCucumberRunner,
|
|
1261
|
+
canRunAll: false
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
function generateStableUid2(itemOrFile, name) {
|
|
1266
|
+
let file, testName;
|
|
1267
|
+
if (typeof itemOrFile === "object" && itemOrFile !== null && name === void 0) {
|
|
1268
|
+
file = itemOrFile.file || "";
|
|
1269
|
+
testName = String(itemOrFile.fullTitle || itemOrFile.title);
|
|
1270
|
+
} else {
|
|
1271
|
+
file = itemOrFile || "";
|
|
1272
|
+
testName = String(name || "");
|
|
1273
|
+
}
|
|
1274
|
+
return generateStableUid(file, testName);
|
|
1275
|
+
}
|
|
1276
|
+
function findTestFileFromStack() {
|
|
1277
|
+
const stack = new Error().stack;
|
|
1278
|
+
if (!stack) {
|
|
1279
|
+
return void 0;
|
|
1280
|
+
}
|
|
1281
|
+
const frames = parseStackTrace2(stack);
|
|
1282
|
+
const preferred = frames.find(
|
|
1283
|
+
(f) => isUserCodeFrame(f) && SPEC_FILE_RE.test(f.file)
|
|
1284
|
+
);
|
|
1285
|
+
const frame = preferred ?? frames.find(isUserCodeFrame);
|
|
1286
|
+
if (!frame?.file) {
|
|
1287
|
+
return void 0;
|
|
1288
|
+
}
|
|
1289
|
+
const filePath = normalizeFilePath(frame.file);
|
|
1290
|
+
return fs7.existsSync(filePath) ? filePath : void 0;
|
|
1291
|
+
}
|
|
1292
|
+
function extractTestMetadata(filePath) {
|
|
1293
|
+
const defs = findTestDefinitions(filePath, {
|
|
1294
|
+
includeNightwatchObjectStyle: true
|
|
1295
|
+
});
|
|
1296
|
+
const firstSuite = defs.find((d) => d.kind === "suite");
|
|
1297
|
+
const tests = defs.filter((d) => d.kind === "test");
|
|
1298
|
+
return {
|
|
1299
|
+
suiteTitle: firstSuite?.title ?? null,
|
|
1300
|
+
suiteLine: firstSuite?.line ?? null,
|
|
1301
|
+
testNames: tests.map((t) => t.title),
|
|
1302
|
+
testLines: tests.map((t) => t.line)
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function collectStepsAfterScenario(lines, scenarioIndex, stepCount) {
|
|
1306
|
+
const stepRe = /^\s*(Given|When|Then|And|But)\s+/i;
|
|
1307
|
+
const stepLines = [];
|
|
1308
|
+
const stepKeywords = [];
|
|
1309
|
+
for (let j = scenarioIndex + 1; j < lines.length && stepLines.length < stepCount; j++) {
|
|
1310
|
+
if (/^\s*(?:Scenario:|Feature:)/i.test(lines[j])) {
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
const m = stepRe.exec(lines[j]);
|
|
1314
|
+
if (m) {
|
|
1315
|
+
stepLines.push(j + 1);
|
|
1316
|
+
stepKeywords.push(m[1]);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return { stepLines, stepKeywords };
|
|
1320
|
+
}
|
|
1321
|
+
function parseCucumberScenario(featureContent, scenarioName, stepTexts) {
|
|
1322
|
+
const stepCount = stepTexts.length;
|
|
1323
|
+
if (!featureContent) {
|
|
1324
|
+
return {
|
|
1325
|
+
featureLine: 1,
|
|
1326
|
+
scenarioLine: 1,
|
|
1327
|
+
stepLines: [],
|
|
1328
|
+
stepKeywords: Array(stepCount).fill("")
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
const lines = featureContent.split("\n");
|
|
1332
|
+
let featureLine = 1;
|
|
1333
|
+
let scenarioLine = 1;
|
|
1334
|
+
let stepLines = [];
|
|
1335
|
+
let stepKeywords = [];
|
|
1336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1337
|
+
const line = lines[i];
|
|
1338
|
+
const lineNum = i + 1;
|
|
1339
|
+
if (featureLine === 1 && /^\s*Feature:/i.test(line)) {
|
|
1340
|
+
featureLine = lineNum;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (/^\s*Scenario:/i.test(line) && line.includes(scenarioName)) {
|
|
1344
|
+
scenarioLine = lineNum;
|
|
1345
|
+
const collected = collectStepsAfterScenario(lines, i, stepCount);
|
|
1346
|
+
stepLines = collected.stepLines;
|
|
1347
|
+
stepKeywords = collected.stepKeywords;
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
while (stepKeywords.length < stepCount) {
|
|
1352
|
+
stepKeywords.push("");
|
|
1353
|
+
}
|
|
1354
|
+
return { featureLine, scenarioLine, stepLines, stepKeywords };
|
|
1355
|
+
}
|
|
1356
|
+
var CUCUMBER_TYPE_PATTERNS = {
|
|
1357
|
+
int: "-?\\d+",
|
|
1358
|
+
float: "-?\\d+(?:\\.\\d+)?",
|
|
1359
|
+
string: `(?:"[^"]*"|'[^']*')`,
|
|
1360
|
+
word: "\\S+"
|
|
1361
|
+
};
|
|
1362
|
+
function matchesCucumberExpression(pattern, text) {
|
|
1363
|
+
if (pattern === text) {
|
|
1364
|
+
return true;
|
|
1365
|
+
}
|
|
1366
|
+
const regexSource = pattern.split(/(\{[^}]*\})/).map((part, idx) => {
|
|
1367
|
+
if (idx % 2 === 1) {
|
|
1368
|
+
const type = part.slice(1, -1);
|
|
1369
|
+
return CUCUMBER_TYPE_PATTERNS[type] ?? "[\\s\\S]+";
|
|
1370
|
+
}
|
|
1371
|
+
return part.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
|
1372
|
+
}).join("");
|
|
1373
|
+
try {
|
|
1374
|
+
return new RegExp(`^${regexSource}$`).test(text);
|
|
1375
|
+
} catch {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function findStepDefinitionLine(stepDefFiles, stepText) {
|
|
1380
|
+
const stepDefRe = /(?:Given|When|Then|And|But)\s*\(/i;
|
|
1381
|
+
for (const { filePath, content } of stepDefFiles) {
|
|
1382
|
+
const lines = content.split("\n");
|
|
1383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1384
|
+
const line = lines[i];
|
|
1385
|
+
if (!stepDefRe.test(line)) {
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
const literalMatch = line.match(
|
|
1389
|
+
/(?:Given|When|Then|And|But)\s*\(\s*(['"`])(.*?)\1/i
|
|
1390
|
+
);
|
|
1391
|
+
if (literalMatch) {
|
|
1392
|
+
if (matchesCucumberExpression(literalMatch[2], stepText)) {
|
|
1393
|
+
return { filePath, line: i + 1 };
|
|
166
1394
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.testReporter.updateSuites();
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
const regexMatch = line.match(
|
|
1398
|
+
/(?:Given|When|Then|And|But)\s*\(\s*\/(.+?)\/([gimy]*)/i
|
|
1399
|
+
);
|
|
1400
|
+
if (regexMatch) {
|
|
1401
|
+
try {
|
|
1402
|
+
if (new RegExp(regexMatch[1], regexMatch[2]).test(stepText)) {
|
|
1403
|
+
return { filePath, line: i + 1 };
|
|
1404
|
+
}
|
|
1405
|
+
} catch {
|
|
179
1406
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
function resolveNightwatchConfig() {
|
|
1413
|
+
const configFlagIdx = process.argv.findIndex(
|
|
1414
|
+
(arg) => arg === "--config" || arg === "-c"
|
|
1415
|
+
);
|
|
1416
|
+
if (configFlagIdx !== -1 && process.argv[configFlagIdx + 1]) {
|
|
1417
|
+
const argvConfig = process.argv[configFlagIdx + 1];
|
|
1418
|
+
const resolved = path5.isAbsolute(argvConfig) ? argvConfig : path5.resolve(process.cwd(), argvConfig);
|
|
1419
|
+
if (fs7.existsSync(resolved)) {
|
|
1420
|
+
return resolved;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
let dir = process.cwd();
|
|
1424
|
+
const root = path5.parse(dir).root;
|
|
1425
|
+
while (dir && dir !== root) {
|
|
1426
|
+
for (const file of CONFIG_FILENAMES) {
|
|
1427
|
+
const candidate = path5.join(dir, file);
|
|
1428
|
+
if (fs7.existsSync(candidate)) {
|
|
1429
|
+
return candidate;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const parent = path5.dirname(dir);
|
|
1433
|
+
if (parent === dir) {
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
dir = parent;
|
|
1437
|
+
}
|
|
1438
|
+
return void 0;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// src/run-lifecycle.ts
|
|
1442
|
+
var log = logger("@wdio/nightwatch-devtools:run-lifecycle");
|
|
1443
|
+
function handleReuseMode(ctx) {
|
|
1444
|
+
ctx.options.hostname = process.env[REUSE_ENV.HOST];
|
|
1445
|
+
ctx.options.port = Number(process.env[REUSE_ENV.PORT]);
|
|
1446
|
+
log.info(
|
|
1447
|
+
`\u267B Reusing DevTools backend at ${ctx.options.hostname}:${ctx.options.port}`
|
|
1448
|
+
);
|
|
1449
|
+
if (ctx.testReporter) {
|
|
1450
|
+
ctx.clearExecutionData();
|
|
1451
|
+
ctx.passCount = 0;
|
|
1452
|
+
ctx.failCount = 0;
|
|
1453
|
+
ctx.skipCount = 0;
|
|
1454
|
+
log.info("Cleared execution data for rerun");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
async function runPluginBefore(ctx) {
|
|
1458
|
+
const isReuse = process.env[REUSE_ENV.REUSE] === "1" && !!process.env[REUSE_ENV.HOST] && !!process.env[REUSE_ENV.PORT];
|
|
1459
|
+
if (isReuse) {
|
|
1460
|
+
ctx.handleReuse();
|
|
1461
|
+
}
|
|
1462
|
+
const configPath = resolveNightwatchConfig();
|
|
1463
|
+
ctx.setConfigPath(configPath);
|
|
1464
|
+
if (configPath) {
|
|
1465
|
+
log.info(`\u2713 Config: ${configPath}`);
|
|
1466
|
+
} else {
|
|
1467
|
+
log.warn(
|
|
1468
|
+
"Could not find nightwatch config \u2014 test rerun will be unavailable"
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
if (isReuse) {
|
|
1472
|
+
;
|
|
1473
|
+
globalThis[PLUGIN_GLOBAL_KEY] = ctx.plugin;
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
ctx.options.port = await findFreePort(
|
|
1478
|
+
ctx.options.port,
|
|
1479
|
+
ctx.options.hostname
|
|
1480
|
+
);
|
|
1481
|
+
log.info("\u{1F680} Starting DevTools backend...");
|
|
1482
|
+
const { port } = await start(ctx.options);
|
|
1483
|
+
ctx.options.port = port;
|
|
1484
|
+
const url = `http://${ctx.options.hostname}:${ctx.options.port}`;
|
|
1485
|
+
log.info(`\u2713 Backend started on port ${ctx.options.port}`);
|
|
1486
|
+
log.info(` DevTools UI: ${url}`);
|
|
1487
|
+
await ctx.openDevtoolsBrowserAt(url);
|
|
1488
|
+
await new Promise(
|
|
1489
|
+
(resolve2) => setTimeout(resolve2, TIMING.UI_CONNECTION_WAIT)
|
|
1490
|
+
);
|
|
1491
|
+
globalThis[PLUGIN_GLOBAL_KEY] = ctx.plugin;
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
log.error(`Failed to start backend: ${errorMessage(err)}`);
|
|
1494
|
+
throw err;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
async function openDevtoolsBrowser(ctx, url) {
|
|
1498
|
+
try {
|
|
1499
|
+
ctx.userDataDir = path6.join(
|
|
1500
|
+
os3.tmpdir(),
|
|
1501
|
+
`nightwatch-devtools-${ctx.options.port}-${Date.now()}`
|
|
1502
|
+
);
|
|
1503
|
+
if (!fs8.existsSync(ctx.userDataDir)) {
|
|
1504
|
+
fs8.mkdirSync(ctx.userDataDir, { recursive: true });
|
|
1505
|
+
}
|
|
1506
|
+
ctx.devtoolsBrowser = await remote({
|
|
1507
|
+
logLevel: "info",
|
|
1508
|
+
automationProtocol: "devtools",
|
|
1509
|
+
capabilities: {
|
|
1510
|
+
browserName: "chrome",
|
|
1511
|
+
"goog:chromeOptions": {
|
|
1512
|
+
args: [
|
|
1513
|
+
"--window-size=1600,1200",
|
|
1514
|
+
`--user-data-dir=${ctx.userDataDir}`,
|
|
1515
|
+
"--no-first-run",
|
|
1516
|
+
"--no-default-browser-check"
|
|
1517
|
+
]
|
|
188
1518
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
await ctx.devtoolsBrowser.url(url);
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
log.error(`Failed to open DevTools UI: ${errorMessage(err)}`);
|
|
1524
|
+
log.info(`Please manually open: ${url}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
async function finalizeAllSuites(ctx, browser) {
|
|
1528
|
+
const currentTest = browser?.currentTest;
|
|
1529
|
+
const testcases = currentTest?.results?.testcases ?? {};
|
|
1530
|
+
for (const [, suite] of (ctx.suiteManager?.getAllSuites() ?? /* @__PURE__ */ new Map()).entries()) {
|
|
1531
|
+
ctx.testManager.finalizeSuiteTests(suite, testcases);
|
|
1532
|
+
await new Promise(
|
|
1533
|
+
(resolve2) => setTimeout(resolve2, TIMING.SUITE_COMPLETE_DELAY)
|
|
1534
|
+
);
|
|
1535
|
+
ctx.suiteManager?.finalizeSuite(suite);
|
|
1536
|
+
}
|
|
1537
|
+
await new Promise(
|
|
1538
|
+
(resolve2) => setTimeout(resolve2, TIMING.SUITE_COMPLETE_DELAY)
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
function logRunSummary(ctx) {
|
|
1542
|
+
const summary = [
|
|
1543
|
+
ctx.passCount > 0 ? `${ctx.passCount} passed` : null,
|
|
1544
|
+
ctx.failCount > 0 ? `${ctx.failCount} failed` : null,
|
|
1545
|
+
ctx.skipCount > 0 ? `${ctx.skipCount} skipped` : null
|
|
1546
|
+
].filter(Boolean).join(" ");
|
|
1547
|
+
log.info(`${ctx.failCount > 0 ? "\u274C" : "\u2705"} Tests complete! ${summary}`);
|
|
1548
|
+
log.info(` DevTools UI: http://${ctx.options.hostname}:${ctx.options.port}`);
|
|
1549
|
+
}
|
|
1550
|
+
async function waitForDevtoolsBrowserClose(ctx) {
|
|
1551
|
+
if (!ctx.devtoolsBrowser) {
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
;
|
|
1555
|
+
logger.setLevel(
|
|
1556
|
+
"devtools",
|
|
1557
|
+
"warn"
|
|
1558
|
+
);
|
|
1559
|
+
let exitBySignal = false;
|
|
1560
|
+
const signalHandler = () => {
|
|
1561
|
+
exitBySignal = true;
|
|
1562
|
+
log.info("\n\u2713 Exiting... Browser window will remain open");
|
|
1563
|
+
process.exit(0);
|
|
1564
|
+
};
|
|
1565
|
+
process.once("SIGINT", signalHandler);
|
|
1566
|
+
process.once("SIGTERM", signalHandler);
|
|
1567
|
+
while (true) {
|
|
1568
|
+
try {
|
|
1569
|
+
await ctx.devtoolsBrowser.getTitle();
|
|
1570
|
+
await new Promise((res) => setTimeout(res, TIMING.BROWSER_POLL_INTERVAL));
|
|
1571
|
+
} catch {
|
|
1572
|
+
if (!exitBySignal) {
|
|
1573
|
+
log.info("Browser window closed, stopping DevTools app");
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (exitBySignal) {
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
process.removeListener("SIGINT", signalHandler);
|
|
1582
|
+
process.removeListener("SIGTERM", signalHandler);
|
|
1583
|
+
logger.setLevel(
|
|
1584
|
+
"devtools",
|
|
1585
|
+
"info"
|
|
1586
|
+
);
|
|
1587
|
+
try {
|
|
1588
|
+
await ctx.devtoolsBrowser.deleteSession();
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
await stop();
|
|
1592
|
+
process.exit(0);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// src/event-hub.ts
|
|
1596
|
+
import logger2 from "@wdio/logger";
|
|
1597
|
+
var log2 = logger2("@wdio/nightwatch-devtools:event-hub");
|
|
1598
|
+
function makeSessionMetadataHandler(bindings) {
|
|
1599
|
+
return (data) => {
|
|
1600
|
+
try {
|
|
1601
|
+
const md = data?.metadata ?? {};
|
|
1602
|
+
const capturer = bindings.getSessionCapturer();
|
|
1603
|
+
const sessionCapabilities = md.sessionCapabilities;
|
|
1604
|
+
const sessionId = md.sessionId;
|
|
1605
|
+
if (!capturer || !sessionCapabilities && !sessionId) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
capturer.sendUpstream("metadata", {
|
|
1609
|
+
type: "testrunner" /* Testrunner */,
|
|
1610
|
+
capabilities: sessionCapabilities ?? {},
|
|
1611
|
+
sessionId,
|
|
1612
|
+
testEnv: md.testEnv,
|
|
1613
|
+
host: md.host,
|
|
1614
|
+
modulePath: md.modulePath,
|
|
1615
|
+
options: bindings.buildMetadataOptions()
|
|
1616
|
+
});
|
|
1617
|
+
} catch (err) {
|
|
1618
|
+
log2.error(`Error in event handler: ${errorMessage(err)}`);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
function registerEventHandlers(eventHub, bindings) {
|
|
1623
|
+
bindings.setCucumberRunner(eventHub.runner === "cucumber");
|
|
1624
|
+
if (eventHub.runner === "cucumber") {
|
|
1625
|
+
log2.info("\u2713 Cucumber runner detected via NightwatchEventHub");
|
|
1626
|
+
}
|
|
1627
|
+
log2.info("\u2713 NightwatchEventHub registered \u2014 enriched metadata enabled");
|
|
1628
|
+
const handler = makeSessionMetadataHandler(bindings);
|
|
1629
|
+
eventHub.on("TestSuiteStarted", handler);
|
|
1630
|
+
eventHub.on("TestRunStarted", handler);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/cucumber-lifecycle.ts
|
|
1634
|
+
import logger3 from "@wdio/logger";
|
|
1635
|
+
|
|
1636
|
+
// src/helpers/cucumberResult.ts
|
|
1637
|
+
function cucumberResultToTestState(result) {
|
|
1638
|
+
const status = String(
|
|
1639
|
+
result?.status ?? "UNKNOWN"
|
|
1640
|
+
).toUpperCase();
|
|
1641
|
+
if (status === "PASSED") {
|
|
1642
|
+
return TEST_STATE.PASSED;
|
|
1643
|
+
}
|
|
1644
|
+
if (status === "SKIPPED") {
|
|
1645
|
+
return TEST_STATE.SKIPPED;
|
|
1646
|
+
}
|
|
1647
|
+
return TEST_STATE.FAILED;
|
|
1648
|
+
}
|
|
1649
|
+
function closeOpenSteps(suite, scenarioState, now = /* @__PURE__ */ new Date()) {
|
|
1650
|
+
for (const step of suite.tests) {
|
|
1651
|
+
if (typeof step === "string") {
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
if (step.state === "running" || step.state === "pending") {
|
|
1655
|
+
step.state = scenarioState === TEST_STATE.PASSED ? TEST_STATE.PASSED : TEST_STATE.FAILED;
|
|
1656
|
+
step.end = now;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/helpers/cucumberScenarioBuilder.ts
|
|
1662
|
+
function buildScenarioStepTest(input, scenarioUid, i) {
|
|
1663
|
+
const {
|
|
1664
|
+
featureUri,
|
|
1665
|
+
scenarioName,
|
|
1666
|
+
featureAbsPath,
|
|
1667
|
+
stepDefFiles,
|
|
1668
|
+
steps,
|
|
1669
|
+
stepLines,
|
|
1670
|
+
stepKeywords
|
|
1671
|
+
} = input;
|
|
1672
|
+
const step = steps[i];
|
|
1673
|
+
const keyword = stepKeywords[i] || "";
|
|
1674
|
+
const stepLabel = keyword ? `${keyword} ${step.text}` : step.text;
|
|
1675
|
+
const stepDefLoc = findStepDefinitionLine(stepDefFiles, step.text);
|
|
1676
|
+
const callSource = stepDefLoc ? `${stepDefLoc.filePath}:${stepDefLoc.line}` : featureAbsPath && stepLines[i] > 0 ? `${featureAbsPath}:${stepLines[i]}` : void 0;
|
|
1677
|
+
return {
|
|
1678
|
+
uid: deterministicUid(featureUri, `step:${scenarioName}:${step.text}`),
|
|
1679
|
+
cid: DEFAULTS.CID,
|
|
1680
|
+
title: stepLabel,
|
|
1681
|
+
fullTitle: `${scenarioName} ${stepLabel}`,
|
|
1682
|
+
parent: scenarioUid,
|
|
1683
|
+
state: TEST_STATE.PENDING,
|
|
1684
|
+
start: /* @__PURE__ */ new Date(),
|
|
1685
|
+
end: null,
|
|
1686
|
+
type: "test",
|
|
1687
|
+
file: featureUri,
|
|
1688
|
+
retries: 0,
|
|
1689
|
+
_duration: 0,
|
|
1690
|
+
hooks: [],
|
|
1691
|
+
callSource
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
function buildCucumberScenarioSuite(input) {
|
|
1695
|
+
const {
|
|
1696
|
+
featureUri,
|
|
1697
|
+
scenarioName,
|
|
1698
|
+
featureName,
|
|
1699
|
+
featureAbsPath,
|
|
1700
|
+
steps,
|
|
1701
|
+
scenarioLine,
|
|
1702
|
+
parentFeatureSuiteUid
|
|
1703
|
+
} = input;
|
|
1704
|
+
const scenarioUid = deterministicUid(featureUri, `scenario:${scenarioName}`);
|
|
1705
|
+
const scenarioSuite = {
|
|
1706
|
+
uid: scenarioUid,
|
|
1707
|
+
cid: DEFAULTS.CID,
|
|
1708
|
+
title: scenarioName,
|
|
1709
|
+
fullTitle: `${featureName} ${scenarioName}`,
|
|
1710
|
+
parent: parentFeatureSuiteUid,
|
|
1711
|
+
type: "suite",
|
|
1712
|
+
file: featureUri,
|
|
1713
|
+
start: /* @__PURE__ */ new Date(),
|
|
1714
|
+
state: TEST_STATE.RUNNING,
|
|
1715
|
+
end: null,
|
|
1716
|
+
tests: [],
|
|
1717
|
+
suites: [],
|
|
1718
|
+
hooks: [],
|
|
1719
|
+
_duration: 0,
|
|
1720
|
+
callSource: featureAbsPath && scenarioLine > 0 ? `${featureAbsPath}:${scenarioLine}` : void 0
|
|
1721
|
+
};
|
|
1722
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1723
|
+
scenarioSuite.tests.push(buildScenarioStepTest(input, scenarioUid, i));
|
|
1724
|
+
}
|
|
1725
|
+
return scenarioSuite;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// src/helpers/featureFileScan.ts
|
|
1729
|
+
import fs9 from "fs";
|
|
1730
|
+
import path7 from "path";
|
|
1731
|
+
function scanFeatureFile(featureUri) {
|
|
1732
|
+
const featureAbsPath = path7.resolve(process.cwd(), featureUri);
|
|
1733
|
+
const result = {
|
|
1734
|
+
featureName: path7.basename(featureUri, ".feature"),
|
|
1735
|
+
featureContent: "",
|
|
1736
|
+
featureAbsPath,
|
|
1737
|
+
stepDefFiles: [],
|
|
1738
|
+
capturedPaths: []
|
|
1739
|
+
};
|
|
1740
|
+
if (featureUri === "unknown.feature" || !fs9.existsSync(featureAbsPath)) {
|
|
1741
|
+
return result;
|
|
1742
|
+
}
|
|
1743
|
+
result.featureContent = fs9.readFileSync(featureAbsPath, "utf-8");
|
|
1744
|
+
const match = result.featureContent.match(/^\s*Feature:\s*(.+)/m);
|
|
1745
|
+
if (match) {
|
|
1746
|
+
result.featureName = match[1].trim();
|
|
1747
|
+
}
|
|
1748
|
+
result.capturedPaths.push(featureAbsPath);
|
|
1749
|
+
const featureDir = path7.dirname(featureAbsPath);
|
|
1750
|
+
const stepDirCandidates = ["step_definitions", "steps", "support"];
|
|
1751
|
+
for (const candidate of stepDirCandidates) {
|
|
1752
|
+
const stepDir = path7.join(featureDir, candidate);
|
|
1753
|
+
if (!fs9.existsSync(stepDir) || !fs9.statSync(stepDir).isDirectory()) {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
for (const entry of fs9.readdirSync(stepDir)) {
|
|
1757
|
+
if (!/\.(js|ts|mjs|cjs)$/.test(entry)) {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const stepFilePath = path7.join(stepDir, entry);
|
|
1761
|
+
result.capturedPaths.push(stepFilePath);
|
|
1762
|
+
try {
|
|
1763
|
+
result.stepDefFiles.push({
|
|
1764
|
+
filePath: stepFilePath,
|
|
1765
|
+
content: fs9.readFileSync(stepFilePath, "utf-8")
|
|
198
1766
|
});
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
log.info(`✓ Browser: ${browserName}${browserVersion ? ' ' + browserVersion : ''} (session: ${sessionId})`);
|
|
202
|
-
const loggingPrefs = capabilities['goog:loggingPrefs'] ||
|
|
203
|
-
desiredCapabilities['goog:loggingPrefs'] ||
|
|
204
|
-
{};
|
|
205
|
-
if (!loggingPrefs.performance) {
|
|
206
|
-
log.warn("⚠ Network tab will be empty — add 'goog:loggingPrefs': { performance: 'ALL' } to your capabilities");
|
|
207
|
-
}
|
|
1767
|
+
} catch {
|
|
1768
|
+
}
|
|
208
1769
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
1770
|
+
}
|
|
1771
|
+
return result;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/cucumber-lifecycle.ts
|
|
1775
|
+
var log3 = logger3("@wdio/nightwatch-devtools:cucumber");
|
|
1776
|
+
function attachScenarioToFeature(ctx, featureSuite, scenarioSuite) {
|
|
1777
|
+
const existingIdx = featureSuite.suites.findIndex(
|
|
1778
|
+
(s) => s.uid === scenarioSuite.uid
|
|
1779
|
+
);
|
|
1780
|
+
if (existingIdx !== -1) {
|
|
1781
|
+
featureSuite.suites[existingIdx] = scenarioSuite;
|
|
1782
|
+
ctx.sessionCapturer.sendUpstream(WS_SCOPE.clearExecutionData, {
|
|
1783
|
+
uid: scenarioSuite.uid,
|
|
1784
|
+
entryType: "suite"
|
|
1785
|
+
});
|
|
1786
|
+
} else {
|
|
1787
|
+
featureSuite.suites.push(scenarioSuite);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
function createFeatureSuite(ctx, featureUri, featureName, featureContent, featureAbsPath, scenarioName, steps) {
|
|
1791
|
+
const featureSuite = ctx.suiteManager.getOrCreateSuite(
|
|
1792
|
+
featureUri,
|
|
1793
|
+
featureName,
|
|
1794
|
+
featureUri,
|
|
1795
|
+
[]
|
|
1796
|
+
);
|
|
1797
|
+
ctx.suiteManager.markSuiteAsRunning(featureSuite);
|
|
1798
|
+
const { featureLine, scenarioLine, stepLines, stepKeywords } = parseCucumberScenario(
|
|
1799
|
+
featureContent,
|
|
1800
|
+
scenarioName,
|
|
1801
|
+
steps.map((s) => s.text)
|
|
1802
|
+
);
|
|
1803
|
+
if (featureAbsPath && featureLine > 0) {
|
|
1804
|
+
featureSuite.callSource = `${featureAbsPath}:${featureLine}`;
|
|
1805
|
+
}
|
|
1806
|
+
return { featureSuite, scenarioLine, stepLines, stepKeywords };
|
|
1807
|
+
}
|
|
1808
|
+
function normalizeSteps(pickleSteps) {
|
|
1809
|
+
return (pickleSteps ?? []).map((s) => ({ text: s.text ?? "" }));
|
|
1810
|
+
}
|
|
1811
|
+
async function initCucumberScenario(ctx, browser, pickle) {
|
|
1812
|
+
await ctx.ensureSessionInitialized(browser);
|
|
1813
|
+
const featureUri = pickle.uri ?? "unknown.feature";
|
|
1814
|
+
const scenarioName = pickle.name ?? "Unknown Scenario";
|
|
1815
|
+
const steps = normalizeSteps(pickle.steps);
|
|
1816
|
+
const {
|
|
1817
|
+
featureName,
|
|
1818
|
+
featureContent,
|
|
1819
|
+
featureAbsPath,
|
|
1820
|
+
stepDefFiles,
|
|
1821
|
+
capturedPaths
|
|
1822
|
+
} = scanFeatureFile(featureUri);
|
|
1823
|
+
for (const p of capturedPaths) {
|
|
1824
|
+
ctx.sessionCapturer.captureSource(p).catch(() => {
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
const { featureSuite, scenarioLine, stepLines, stepKeywords } = createFeatureSuite(
|
|
1828
|
+
ctx,
|
|
1829
|
+
featureUri,
|
|
1830
|
+
featureName,
|
|
1831
|
+
featureContent,
|
|
1832
|
+
featureAbsPath,
|
|
1833
|
+
scenarioName,
|
|
1834
|
+
steps
|
|
1835
|
+
);
|
|
1836
|
+
const scenarioSuite = buildCucumberScenarioSuite({
|
|
1837
|
+
featureUri,
|
|
1838
|
+
scenarioName,
|
|
1839
|
+
featureName,
|
|
1840
|
+
featureAbsPath,
|
|
1841
|
+
stepDefFiles,
|
|
1842
|
+
steps,
|
|
1843
|
+
stepLines,
|
|
1844
|
+
stepKeywords,
|
|
1845
|
+
scenarioLine,
|
|
1846
|
+
parentFeatureSuiteUid: featureSuite.uid
|
|
1847
|
+
});
|
|
1848
|
+
attachScenarioToFeature(ctx, featureSuite, scenarioSuite);
|
|
1849
|
+
ctx.setCurrentScenarioSuite(scenarioSuite);
|
|
1850
|
+
ctx.setCurrentStep(null);
|
|
1851
|
+
ctx.setCurrentTest(null);
|
|
1852
|
+
ctx.testReporter.updateSuites();
|
|
1853
|
+
ctx.wrapBrowserOnce(browser);
|
|
1854
|
+
log3.info(`\u{1F952} Scenario: ${scenarioName}`);
|
|
1855
|
+
}
|
|
1856
|
+
async function finalizeCucumberScenario(ctx, browser, result, pickle) {
|
|
1857
|
+
try {
|
|
1858
|
+
const scenarioState = cucumberResultToTestState(result);
|
|
1859
|
+
const scenario = ctx.getCurrentScenarioSuite();
|
|
1860
|
+
if (scenario) {
|
|
1861
|
+
const now = /* @__PURE__ */ new Date();
|
|
1862
|
+
const duration = now.getTime() - (scenario.start?.getTime() ?? now.getTime());
|
|
1863
|
+
scenario.state = scenarioState;
|
|
1864
|
+
scenario.end = now;
|
|
1865
|
+
scenario._duration = duration;
|
|
1866
|
+
closeOpenSteps(scenario, scenarioState, now);
|
|
1867
|
+
const featureUri = pickle?.uri ?? "unknown.feature";
|
|
1868
|
+
ctx.testManager.markTestAsProcessed(featureUri, pickle?.name ?? "");
|
|
1869
|
+
const featureSuite = ctx.suiteManager.getSuite(featureUri);
|
|
1870
|
+
if (featureSuite) {
|
|
1871
|
+
ctx.suiteManager.finalizeSuiteState(featureSuite);
|
|
1872
|
+
}
|
|
1873
|
+
ctx.incrementCount(scenarioState);
|
|
1874
|
+
const icon = ctx.testIcon(scenarioState);
|
|
1875
|
+
log3.info(
|
|
1876
|
+
` ${icon} ${pickle?.name ?? "Unknown"} (${(duration / 1e3).toFixed(2)}s)`
|
|
1877
|
+
);
|
|
1878
|
+
ctx.testReporter.updateSuites();
|
|
1879
|
+
ctx.setCurrentScenarioSuite(null);
|
|
1880
|
+
ctx.setCurrentStep(null);
|
|
1881
|
+
ctx.setCurrentTest(null);
|
|
1882
|
+
}
|
|
1883
|
+
await ctx.sessionCapturer.captureTrace(browser);
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
log3.error(`Failed to finalize Cucumber scenario: ${errorMessage(err)}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
async function cucumberBeforeStep(ctx, _browser, pickleStep, _pickle) {
|
|
1889
|
+
const scenario = ctx.getCurrentScenarioSuite();
|
|
1890
|
+
if (!scenario) {
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
ctx.browserProxy?.resetCommandTracking();
|
|
1894
|
+
const stepText = pickleStep?.text ?? "";
|
|
1895
|
+
const step = scenario.tests.find(
|
|
1896
|
+
(t) => typeof t !== "string" && (t.title?.endsWith(stepText) === true || t.title === stepText)
|
|
1897
|
+
);
|
|
1898
|
+
if (step) {
|
|
1899
|
+
step.state = TEST_STATE.RUNNING;
|
|
1900
|
+
step.start = /* @__PURE__ */ new Date();
|
|
1901
|
+
step.end = null;
|
|
1902
|
+
ctx.setCurrentStep(step);
|
|
1903
|
+
ctx.testReporter.updateSuites();
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
async function cucumberAfterStep(ctx, _browser, result, pickleStep, _pickle) {
|
|
1907
|
+
const step = ctx.getCurrentStep();
|
|
1908
|
+
if (!step) {
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const status = String(result?.status ?? "UNKNOWN").toUpperCase();
|
|
1912
|
+
const stepState = status === "PASSED" ? TEST_STATE.PASSED : status === "SKIPPED" ? TEST_STATE.SKIPPED : TEST_STATE.FAILED;
|
|
1913
|
+
step.state = stepState;
|
|
1914
|
+
step.end = /* @__PURE__ */ new Date();
|
|
1915
|
+
step._duration = Date.now() - (step.start?.getTime() ?? Date.now());
|
|
1916
|
+
ctx.setCurrentStep(null);
|
|
1917
|
+
ctx.testReporter.updateSuites();
|
|
1918
|
+
void pickleStep;
|
|
1919
|
+
}
|
|
1920
|
+
async function cucumberBefore(ctx, browser, pickle) {
|
|
1921
|
+
ctx.setCucumberRunner(true);
|
|
1922
|
+
await initCucumberScenario(ctx, browser, pickle);
|
|
1923
|
+
}
|
|
1924
|
+
async function cucumberAfter(ctx, browser, result, pickle) {
|
|
1925
|
+
await finalizeCucumberScenario(ctx, browser, result, pickle);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// src/test-lifecycle.ts
|
|
1929
|
+
import logger5 from "@wdio/logger";
|
|
1930
|
+
|
|
1931
|
+
// src/helpers/specFileResolver.ts
|
|
1932
|
+
import fs10 from "fs";
|
|
1933
|
+
import path8 from "path";
|
|
1934
|
+
function resolveSpecFilePath(testFile, modulePath, srcFolders, cachedPath) {
|
|
1935
|
+
let fullPath = findTestFileFromStack() || null;
|
|
1936
|
+
if (!fullPath && cachedPath && cachedPath.includes(testFile)) {
|
|
1937
|
+
fullPath = cachedPath;
|
|
1938
|
+
}
|
|
1939
|
+
if (fullPath) {
|
|
1940
|
+
return fullPath;
|
|
1941
|
+
}
|
|
1942
|
+
if (!testFile) {
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
const workspaceRoot = process.cwd();
|
|
1946
|
+
const normalized = (modulePath || "").replace(/\\/g, "/");
|
|
1947
|
+
const srcFolderPaths = srcFolders.flatMap(
|
|
1948
|
+
(sf) => normalized ? [
|
|
1949
|
+
path8.resolve(workspaceRoot, sf, normalized + ".js"),
|
|
1950
|
+
path8.resolve(workspaceRoot, sf, normalized + ".ts"),
|
|
1951
|
+
path8.resolve(workspaceRoot, sf, normalized + ".cjs"),
|
|
1952
|
+
path8.resolve(workspaceRoot, sf, normalized)
|
|
1953
|
+
] : []
|
|
1954
|
+
);
|
|
1955
|
+
const possiblePaths = [
|
|
1956
|
+
...srcFolderPaths,
|
|
1957
|
+
// Treat module path as relative to cwd (works when src_folders isn't nested)
|
|
1958
|
+
...normalized ? [
|
|
1959
|
+
path8.resolve(workspaceRoot, normalized + ".js"),
|
|
1960
|
+
path8.resolve(workspaceRoot, normalized + ".ts"),
|
|
1961
|
+
path8.resolve(workspaceRoot, normalized + ".cjs"),
|
|
1962
|
+
path8.resolve(workspaceRoot, normalized)
|
|
1963
|
+
] : [],
|
|
1964
|
+
path8.resolve(workspaceRoot, "tests", testFile + ".js"),
|
|
1965
|
+
path8.resolve(workspaceRoot, "test", testFile + ".js"),
|
|
1966
|
+
path8.resolve(workspaceRoot, testFile + ".js")
|
|
1967
|
+
];
|
|
1968
|
+
for (const candidate of possiblePaths) {
|
|
1969
|
+
if (fs10.existsSync(candidate)) {
|
|
1970
|
+
return candidate;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
return null;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// src/helpers/closePreviousTest.ts
|
|
1977
|
+
import logger4 from "@wdio/logger";
|
|
1978
|
+
var log4 = logger4("@wdio/nightwatch-devtools:closePreviousTest");
|
|
1979
|
+
async function closePreviousTest(input) {
|
|
1980
|
+
const {
|
|
1981
|
+
runningTest,
|
|
1982
|
+
testFile,
|
|
1983
|
+
testcases,
|
|
1984
|
+
testManager,
|
|
1985
|
+
incrementCount,
|
|
1986
|
+
testIcon
|
|
1987
|
+
} = input;
|
|
1988
|
+
if (testcases[runningTest.title]) {
|
|
1989
|
+
const testcase = testcases[runningTest.title];
|
|
1990
|
+
const testState = determineTestState(testcase);
|
|
1991
|
+
runningTest.state = testState;
|
|
1992
|
+
runningTest.end = /* @__PURE__ */ new Date();
|
|
1993
|
+
runningTest._duration = parseFloat(testcase.time || "0") * 1e3;
|
|
1994
|
+
testManager.updateTestState(runningTest, testState);
|
|
1995
|
+
testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
1996
|
+
incrementCount(testState);
|
|
1997
|
+
log4.info(
|
|
1998
|
+
` ${testIcon(testState)} ${runningTest.title} (${(runningTest._duration / 1e3).toFixed(2)}s)`
|
|
1999
|
+
);
|
|
2000
|
+
} else {
|
|
2001
|
+
const endTime = /* @__PURE__ */ new Date();
|
|
2002
|
+
const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
|
|
2003
|
+
testManager.updateTestState(
|
|
2004
|
+
runningTest,
|
|
2005
|
+
TEST_STATE.PASSED,
|
|
2006
|
+
endTime,
|
|
2007
|
+
duration
|
|
2008
|
+
);
|
|
2009
|
+
testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
2010
|
+
incrementCount(TEST_STATE.PASSED);
|
|
2011
|
+
log4.info(` \u2705 ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
|
|
2012
|
+
}
|
|
2013
|
+
await new Promise((resolve2) => setTimeout(resolve2, TIMING.UI_RENDER_DELAY));
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// src/test-lifecycle.ts
|
|
2017
|
+
var log5 = logger5("@wdio/nightwatch-devtools:test-lifecycle");
|
|
2018
|
+
function resolveSuiteMetadata(ctx, currentTest) {
|
|
2019
|
+
const moduleName = currentTest.module ?? "";
|
|
2020
|
+
const testFile = moduleName.split("/").pop() || moduleName || DEFAULTS.FILE_NAME;
|
|
2021
|
+
const fullPath = resolveSpecFilePath(
|
|
2022
|
+
testFile,
|
|
2023
|
+
moduleName,
|
|
2024
|
+
ctx.srcFolders,
|
|
2025
|
+
ctx.browserProxy.getCurrentTestFullPath() || void 0
|
|
2026
|
+
);
|
|
2027
|
+
if (!fullPath) {
|
|
2028
|
+
log5.warn(
|
|
2029
|
+
`[beforeEach] Could not resolve file path for "${testFile}" \u2014 source view will be unavailable`
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
let suiteTitle = testFile;
|
|
2033
|
+
let testNames = [];
|
|
2034
|
+
let suiteLine = null;
|
|
2035
|
+
let testLines = [];
|
|
2036
|
+
if (fullPath) {
|
|
2037
|
+
const parsed = extractTestMetadata(fullPath);
|
|
2038
|
+
if (parsed.suiteTitle) {
|
|
2039
|
+
suiteTitle = parsed.suiteTitle;
|
|
2040
|
+
}
|
|
2041
|
+
testNames = parsed.testNames;
|
|
2042
|
+
suiteLine = parsed.suiteLine;
|
|
2043
|
+
testLines = parsed.testLines;
|
|
2044
|
+
}
|
|
2045
|
+
const rerunLabel = ctx.getRerunLabel();
|
|
2046
|
+
if (rerunLabel) {
|
|
2047
|
+
const targetIndex = testNames.findIndex((name) => name === rerunLabel);
|
|
2048
|
+
if (targetIndex !== -1) {
|
|
2049
|
+
testNames = [testNames[targetIndex]];
|
|
2050
|
+
testLines = testLines[targetIndex] ? [testLines[targetIndex]] : [];
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return { testFile, fullPath, suiteTitle, testNames, suiteLine, testLines };
|
|
2054
|
+
}
|
|
2055
|
+
function pickCurrentTestName(currentTest, testNames, processedTests) {
|
|
2056
|
+
const runtimeTestName = typeof currentTest?.name === "string" ? currentTest.name.trim() : void 0;
|
|
2057
|
+
const matchedRuntimeTestName = runtimeTestName ? testNames.find(
|
|
2058
|
+
(name) => runtimeTestName === name || runtimeTestName.endsWith(` ${name}`)
|
|
2059
|
+
) : void 0;
|
|
2060
|
+
return matchedRuntimeTestName || testNames.find((name) => !processedTests.has(name));
|
|
2061
|
+
}
|
|
2062
|
+
async function startNextTest(ctx, currentSuite, currentTestName, processedTests) {
|
|
2063
|
+
if (processedTests.size === 0) {
|
|
2064
|
+
ctx.suiteManager.markSuiteAsRunning(currentSuite);
|
|
2065
|
+
}
|
|
2066
|
+
const test = ctx.testManager.findTestInSuite(currentSuite, currentTestName);
|
|
2067
|
+
if (test) {
|
|
2068
|
+
test.state = TEST_STATE.RUNNING;
|
|
2069
|
+
test.start = /* @__PURE__ */ new Date();
|
|
2070
|
+
test.end = null;
|
|
2071
|
+
ctx.testReporter.onTestStart(test);
|
|
2072
|
+
ctx.setCurrentTest(test);
|
|
2073
|
+
log5.info(` \u25B6 ${currentTestName}`);
|
|
2074
|
+
await new Promise((resolve2) => setTimeout(resolve2, TIMING.TEST_START_DELAY));
|
|
2075
|
+
} else {
|
|
2076
|
+
log5.warn(
|
|
2077
|
+
`Test "${currentTestName}" not found in suite "${currentSuite.title}"`
|
|
2078
|
+
);
|
|
2079
|
+
ctx.setCurrentTest(null);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
async function closePreviousRunningTest(ctx, currentSuite, testFile, currentTest) {
|
|
2083
|
+
const runningTest = currentSuite.tests.find(
|
|
2084
|
+
(t) => typeof t !== "string" && t.state === TEST_STATE.RUNNING
|
|
2085
|
+
);
|
|
2086
|
+
if (!runningTest) {
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
await closePreviousTest({
|
|
2090
|
+
runningTest,
|
|
2091
|
+
testFile,
|
|
2092
|
+
testcases: currentTest?.results?.testcases || {},
|
|
2093
|
+
testManager: ctx.testManager,
|
|
2094
|
+
incrementCount: (state) => ctx.incrementCount(state),
|
|
2095
|
+
testIcon: (state) => ctx.testIcon(state)
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
function wrapBrowserOnce(ctx, browser) {
|
|
2099
|
+
if (!ctx.isScriptInjected) {
|
|
2100
|
+
ctx.browserProxy.wrapUrlMethod(browser);
|
|
2101
|
+
ctx.isScriptInjected = true;
|
|
2102
|
+
}
|
|
2103
|
+
ctx.browserProxy.resetCommandTracking();
|
|
2104
|
+
ctx.browserProxy.wrapBrowserCommands(browser);
|
|
2105
|
+
}
|
|
2106
|
+
function closeUnreportedRunningTest(ctx, currentSuite, testFile, results, processedTests) {
|
|
2107
|
+
const runningTest = currentSuite.tests.find(
|
|
2108
|
+
(t) => typeof t !== "string" && t.state === TEST_STATE.RUNNING
|
|
2109
|
+
);
|
|
2110
|
+
if (!runningTest || processedTests.has(runningTest.title)) {
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
const failed = (results.errors ?? 0) > 0 || (results.failed ?? 0) > 0;
|
|
2114
|
+
const testState = failed ? TEST_STATE.FAILED : TEST_STATE.PASSED;
|
|
2115
|
+
const endTime = /* @__PURE__ */ new Date();
|
|
2116
|
+
const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
|
|
2117
|
+
ctx.testManager.updateTestState(runningTest, testState, endTime, duration);
|
|
2118
|
+
ctx.testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
2119
|
+
ctx.incrementCount(testState);
|
|
2120
|
+
const icon = ctx.testIcon(testState);
|
|
2121
|
+
log5.info(` ${icon} ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
|
|
2122
|
+
}
|
|
2123
|
+
async function closeReportedTestcases(ctx, currentSuite, testFile, testcases, processedTests) {
|
|
2124
|
+
const testcaseNames = Object.keys(testcases);
|
|
2125
|
+
const unprocessedTests = testcaseNames.filter(
|
|
2126
|
+
(name) => !processedTests.has(name)
|
|
2127
|
+
);
|
|
2128
|
+
for (const currentTestName of unprocessedTests) {
|
|
2129
|
+
const testcase = testcases[currentTestName];
|
|
2130
|
+
const testState = determineTestState(testcase);
|
|
2131
|
+
const test = ctx.testManager.findTestInSuite(currentSuite, currentTestName);
|
|
2132
|
+
if (test) {
|
|
2133
|
+
const dur = parseFloat(testcase.time || "0") * 1e3;
|
|
2134
|
+
ctx.testManager.updateTestState(test, testState, /* @__PURE__ */ new Date(), dur);
|
|
2135
|
+
ctx.incrementCount(testState);
|
|
2136
|
+
const icon = ctx.testIcon(testState);
|
|
2137
|
+
log5.info(` ${icon} ${currentTestName} (${(dur / 1e3).toFixed(2)}s)`);
|
|
2138
|
+
}
|
|
2139
|
+
ctx.testManager.markTestAsProcessed(testFile, currentTestName);
|
|
2140
|
+
}
|
|
2141
|
+
if (processedTests.size === testcaseNames.length) {
|
|
2142
|
+
ctx.suiteManager.finalizeSuite(currentSuite);
|
|
2143
|
+
await new Promise(
|
|
2144
|
+
(resolve2) => setTimeout(resolve2, TIMING.SUITE_COMPLETE_DELAY)
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
async function closeOutTestcases(ctx, browser) {
|
|
2149
|
+
const currentTest = browser.currentTest ?? {};
|
|
2150
|
+
const results = currentTest.results ?? {};
|
|
2151
|
+
const moduleName = currentTest.module ?? "";
|
|
2152
|
+
const testFile = moduleName.split("/").pop() || DEFAULTS.FILE_NAME;
|
|
2153
|
+
const testcases = results.testcases ?? {};
|
|
2154
|
+
const currentSuite = ctx.suiteManager.getSuite(testFile);
|
|
2155
|
+
if (!currentSuite) {
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const processedTests = ctx.testManager.getProcessedTests(testFile);
|
|
2159
|
+
if (Object.keys(testcases).length === 0) {
|
|
2160
|
+
closeUnreportedRunningTest(
|
|
2161
|
+
ctx,
|
|
2162
|
+
currentSuite,
|
|
2163
|
+
testFile,
|
|
2164
|
+
results,
|
|
2165
|
+
processedTests
|
|
2166
|
+
);
|
|
2167
|
+
} else {
|
|
2168
|
+
await closeReportedTestcases(
|
|
2169
|
+
ctx,
|
|
2170
|
+
currentSuite,
|
|
2171
|
+
testFile,
|
|
2172
|
+
testcases,
|
|
2173
|
+
processedTests
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/session-init.ts
|
|
2179
|
+
import logger10 from "@wdio/logger";
|
|
2180
|
+
|
|
2181
|
+
// src/session.ts
|
|
2182
|
+
import http from "http";
|
|
2183
|
+
import logger6 from "@wdio/logger";
|
|
2184
|
+
|
|
2185
|
+
// src/helpers/perfLogs.ts
|
|
2186
|
+
function applyResponseReceived(p, response) {
|
|
2187
|
+
const responseHeaders = {};
|
|
2188
|
+
for (const [k, v] of Object.entries(response.headers || {})) {
|
|
2189
|
+
responseHeaders[k.toLowerCase()] = String(v);
|
|
2190
|
+
}
|
|
2191
|
+
p.status = response.status;
|
|
2192
|
+
p.statusText = response.statusText;
|
|
2193
|
+
p.responseHeaders = responseHeaders;
|
|
2194
|
+
p.mimeType = response.mimeType;
|
|
2195
|
+
p.type = getRequestType(p.url, response.mimeType);
|
|
2196
|
+
}
|
|
2197
|
+
function startPendingRequest(params, entry, pending) {
|
|
2198
|
+
const { requestId, request: req, timestamp } = params;
|
|
2199
|
+
if (!req || typeof timestamp !== "number") {
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
pending.set(requestId, {
|
|
2203
|
+
id: `${entry.timestamp}-${requestId}`,
|
|
2204
|
+
url: req.url,
|
|
2205
|
+
method: req.method,
|
|
2206
|
+
requestHeaders: req.headers ?? {},
|
|
2207
|
+
timestamp: Math.round(timestamp * 1e3),
|
|
2208
|
+
startTime: entry.timestamp
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
function handlePerfLogEvent(method, params, entry, pending, completed) {
|
|
2212
|
+
if (method === "Network.requestWillBeSent") {
|
|
2213
|
+
startPendingRequest(params, entry, pending);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (method === "Network.responseReceived") {
|
|
2217
|
+
const p = pending.get(params.requestId);
|
|
2218
|
+
if (p && params.response) {
|
|
2219
|
+
applyResponseReceived(p, params.response);
|
|
2220
|
+
}
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (method === "Network.loadingFinished") {
|
|
2224
|
+
const p = pending.get(params.requestId);
|
|
2225
|
+
if (p && p.status !== void 0) {
|
|
2226
|
+
p.size = params.encodedDataLength;
|
|
2227
|
+
p.endTime = entry.timestamp;
|
|
2228
|
+
p.time = entry.timestamp - p.startTime;
|
|
2229
|
+
completed.push({ ...p });
|
|
2230
|
+
pending.delete(params.requestId);
|
|
2231
|
+
}
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (method === "Network.loadingFailed") {
|
|
2235
|
+
const p = pending.get(params.requestId);
|
|
2236
|
+
if (p) {
|
|
2237
|
+
p.error = params.errorText;
|
|
2238
|
+
p.endTime = entry.timestamp;
|
|
2239
|
+
p.time = entry.timestamp - p.startTime;
|
|
2240
|
+
completed.push({ ...p });
|
|
2241
|
+
pending.delete(params.requestId);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
function parseNetworkFromPerfLogs(logs) {
|
|
2246
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2247
|
+
const completed = [];
|
|
2248
|
+
for (const entry of logs) {
|
|
2249
|
+
let parsed;
|
|
2250
|
+
try {
|
|
2251
|
+
parsed = JSON.parse(entry.message);
|
|
2252
|
+
} catch {
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
const message = parsed?.message;
|
|
2256
|
+
const method = message?.method;
|
|
2257
|
+
const params = message?.params;
|
|
2258
|
+
if (!method || !params) {
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
handlePerfLogEvent(method, params, entry, pending, completed);
|
|
2262
|
+
}
|
|
2263
|
+
return completed;
|
|
2264
|
+
}
|
|
2265
|
+
function dedupeNetworkRequests(incoming, existing) {
|
|
2266
|
+
const failedKey = (entry) => {
|
|
2267
|
+
try {
|
|
2268
|
+
const u = new URL(entry.url);
|
|
2269
|
+
return `err:${entry.method}:${u.origin}${u.pathname}`;
|
|
2270
|
+
} catch {
|
|
2271
|
+
return `err:${entry.method}:${entry.url}`;
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
const alreadySeen = new Set(
|
|
2275
|
+
existing.map(
|
|
2276
|
+
(r) => r.error !== void 0 ? failedKey(r) : `ok:${r.method}:${r.url}:${r.timestamp}`
|
|
2277
|
+
)
|
|
2278
|
+
);
|
|
2279
|
+
const deduped = [];
|
|
2280
|
+
const seenFailedInBatch = /* @__PURE__ */ new Map();
|
|
2281
|
+
for (const entry of incoming) {
|
|
2282
|
+
if (entry.error !== void 0) {
|
|
2283
|
+
const key = failedKey(entry);
|
|
2284
|
+
if (alreadySeen.has(key)) {
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
const existingIdx = seenFailedInBatch.get(key);
|
|
2288
|
+
if (existingIdx !== void 0) {
|
|
2289
|
+
deduped[existingIdx] = entry;
|
|
2290
|
+
} else {
|
|
2291
|
+
seenFailedInBatch.set(key, deduped.length);
|
|
2292
|
+
deduped.push(entry);
|
|
2293
|
+
}
|
|
2294
|
+
} else {
|
|
2295
|
+
const key = `ok:${entry.method}:${entry.url}:${entry.timestamp}`;
|
|
2296
|
+
if (!alreadySeen.has(key)) {
|
|
2297
|
+
deduped.push(entry);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
return deduped;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/session.ts
|
|
2305
|
+
var log6 = logger6("@wdio/nightwatch-devtools:SessionCapturer");
|
|
2306
|
+
function unwrapDriverValue(result) {
|
|
2307
|
+
if (result && typeof result === "object" && "value" in result) {
|
|
2308
|
+
return result.value;
|
|
2309
|
+
}
|
|
2310
|
+
return result;
|
|
2311
|
+
}
|
|
2312
|
+
var getProp = (obj, key) => obj && typeof obj === "object" ? obj[key] : void 0;
|
|
2313
|
+
var getPath = (obj, ...path9) => path9.reduce((acc, k) => getProp(acc, k), obj);
|
|
2314
|
+
var firstDefined = (obj, ...keys) => {
|
|
2315
|
+
if (!obj || typeof obj !== "object") {
|
|
2316
|
+
return void 0;
|
|
2317
|
+
}
|
|
2318
|
+
const rec = obj;
|
|
2319
|
+
for (const k of keys) {
|
|
2320
|
+
const v = rec[k];
|
|
2321
|
+
if (v !== void 0 && v !== null) {
|
|
2322
|
+
return v;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
return void 0;
|
|
2326
|
+
};
|
|
2327
|
+
function resolveWebDriverAddress(browser) {
|
|
2328
|
+
const transportSettings = getPath(browser, "transport", "settings", "webdriver") || getPath(browser, "queue", "transport", "settings", "webdriver") || getPath(
|
|
2329
|
+
browser,
|
|
2330
|
+
"nightwatchInstance",
|
|
2331
|
+
"transport",
|
|
2332
|
+
"settings",
|
|
2333
|
+
"webdriver"
|
|
2334
|
+
) || {};
|
|
2335
|
+
const opts = getProp(browser, "options") ?? {};
|
|
2336
|
+
const nightwatchSettings = getPath(browser, "nightwatchInstance", "settings") || getPath(browser, "globals", "nightwatchInstance", "settings") || {};
|
|
2337
|
+
const driverHost = String(
|
|
2338
|
+
firstDefined(transportSettings, "host", "server_address") || firstDefined(getProp(opts, "webdriver"), "host") || firstDefined(getProp(nightwatchSettings, "webdriver"), "host") || "localhost"
|
|
2339
|
+
);
|
|
2340
|
+
const driverPort = Number(
|
|
2341
|
+
firstDefined(transportSettings, "port") || firstDefined(getProp(opts, "webdriver"), "port") || firstDefined(getProp(nightwatchSettings, "webdriver"), "port") || 9515
|
|
2342
|
+
);
|
|
2343
|
+
return { driverHost, driverPort };
|
|
2344
|
+
}
|
|
2345
|
+
var SessionCapturer = class extends SessionCapturerBase {
|
|
2346
|
+
#browser;
|
|
2347
|
+
// True once BiDi inspectors are attached — the per-command perf-log network
|
|
2348
|
+
// capture path skips when set, so we don't double-emit network requests.
|
|
2349
|
+
bidiActive = false;
|
|
2350
|
+
constructor(devtoolsOptions = {}, browser) {
|
|
2351
|
+
super(devtoolsOptions);
|
|
2352
|
+
this.#browser = browser;
|
|
2353
|
+
this.patchConsole();
|
|
2354
|
+
this.patchStreams();
|
|
2355
|
+
}
|
|
2356
|
+
onWsOpen() {
|
|
2357
|
+
log6.info("\u2713 Worker WebSocket connected to backend");
|
|
2358
|
+
}
|
|
2359
|
+
onWsError(err) {
|
|
2360
|
+
log6.error(`Couldn't connect to devtools backend: ${errorMessage(err)}`);
|
|
2361
|
+
}
|
|
2362
|
+
onWsClose() {
|
|
2363
|
+
log6.info("Worker WebSocket disconnected");
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Push every captured line into the local `consoleLogs` array so it ends up
|
|
2367
|
+
* in any future trace export, in addition to the live WS broadcast.
|
|
2368
|
+
*/
|
|
2369
|
+
onLine(type, args, source) {
|
|
2370
|
+
const entry = createConsoleLogEntry(type, args, source);
|
|
2371
|
+
this.consoleLogs.push(entry);
|
|
2372
|
+
this.sendUpstream("consoleLogs", [entry]);
|
|
2373
|
+
}
|
|
2374
|
+
async captureCommand(command, args, result, error, testUid, callSource, timestamp) {
|
|
2375
|
+
const serializedError = serializeError(error);
|
|
2376
|
+
const commandId = this.commandCounter++;
|
|
2377
|
+
const commandLogEntry = {
|
|
2378
|
+
_id: commandId,
|
|
2379
|
+
command,
|
|
2380
|
+
args,
|
|
2381
|
+
result,
|
|
2382
|
+
error: serializedError,
|
|
2383
|
+
timestamp: timestamp || Date.now(),
|
|
2384
|
+
callSource,
|
|
2385
|
+
testUid
|
|
2386
|
+
};
|
|
2387
|
+
this.commandsLog.push(commandLogEntry);
|
|
2388
|
+
const isNavigationCommand = NAVIGATION_COMMANDS.some(
|
|
2389
|
+
(cmd) => command.toLowerCase().includes(cmd.toLowerCase())
|
|
2390
|
+
);
|
|
2391
|
+
if (isNavigationCommand && this.#browser && !error) {
|
|
2392
|
+
this.#capturePerformanceData(commandLogEntry, args).catch((err) => {
|
|
2393
|
+
log6.warn(`Failed to capture performance data: ${errorMessage(err)}`);
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
async #capturePerformanceData(commandLogEntry, args) {
|
|
2399
|
+
await new Promise((resolve2) => setTimeout(resolve2, 500));
|
|
2400
|
+
const raw = await this.#browser.execute(CAPTURE_PERFORMANCE_SCRIPT);
|
|
2401
|
+
const payload = unwrapDriverValue(
|
|
2402
|
+
raw
|
|
2403
|
+
);
|
|
2404
|
+
applyPerformanceData(
|
|
2405
|
+
commandLogEntry,
|
|
2406
|
+
payload,
|
|
2407
|
+
typeof args[0] === "string" ? args[0] : void 0
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Replace an already-captured command entry (used for retried commands so
|
|
2412
|
+
* only the final execution result is shown in the UI).
|
|
2413
|
+
* Removes the old entry from commandsLog, revokes its sent-status so the
|
|
2414
|
+
* replacement can be sent, and returns the new entry together with the
|
|
2415
|
+
* old entry's timestamp (so the UI can locate and replace it in-place).
|
|
2416
|
+
*/
|
|
2417
|
+
replaceCommand(oldId, command, args, result, error, testUid, callSource, timestamp) {
|
|
2418
|
+
const idx = this.commandsLog.findIndex(
|
|
2419
|
+
(c) => c._id === oldId
|
|
2420
|
+
);
|
|
2421
|
+
const oldTimestamp = idx !== -1 ? this.commandsLog[idx]?.timestamp ?? 0 : 0;
|
|
2422
|
+
if (idx !== -1) {
|
|
2423
|
+
this.commandsLog.splice(idx, 1);
|
|
2424
|
+
}
|
|
2425
|
+
this.sentCommandIds.delete(oldId);
|
|
2426
|
+
const serializedError = serializeError(error);
|
|
2427
|
+
const commandId = this.commandCounter++;
|
|
2428
|
+
const entry = {
|
|
2429
|
+
_id: commandId,
|
|
2430
|
+
command,
|
|
2431
|
+
args,
|
|
2432
|
+
result,
|
|
2433
|
+
error: serializedError,
|
|
2434
|
+
timestamp: timestamp || Date.now(),
|
|
2435
|
+
callSource,
|
|
2436
|
+
testUid
|
|
2437
|
+
};
|
|
2438
|
+
this.commandsLog.push(entry);
|
|
2439
|
+
return { entry, oldTimestamp };
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Take a screenshot by calling the WebDriver HTTP endpoint directly.
|
|
2443
|
+
* This completely bypasses Nightwatch's command queue so there is no risk
|
|
2444
|
+
* of the request being appended after `end()` / `quit()`.
|
|
2445
|
+
*/
|
|
2446
|
+
// Nightwatch's internal config lives at non-public paths (transport,
|
|
2447
|
+
// queue.transport, nightwatchInstance.settings, globals.nightwatchInstance);
|
|
2448
|
+
// none are in the NightwatchBrowser type. Cast for dynamic access.
|
|
2449
|
+
#resolveDriverEndpoint(browser, sessionId) {
|
|
2450
|
+
const { driverHost, driverPort } = resolveWebDriverAddress(browser);
|
|
2451
|
+
return `http://${driverHost}:${driverPort}/session/${sessionId}/screenshot`;
|
|
2452
|
+
}
|
|
2453
|
+
takeScreenshotViaHttp(browser) {
|
|
2454
|
+
const sessionId = browser.sessionId;
|
|
2455
|
+
if (!sessionId) {
|
|
2456
|
+
return Promise.resolve(null);
|
|
2457
|
+
}
|
|
2458
|
+
const endpoint = this.#resolveDriverEndpoint(browser, sessionId);
|
|
2459
|
+
return new Promise((resolve2) => {
|
|
2460
|
+
const req = http.get(endpoint, (res) => {
|
|
2461
|
+
let body = "";
|
|
2462
|
+
res.on("data", (chunk) => {
|
|
2463
|
+
body += chunk;
|
|
2464
|
+
});
|
|
2465
|
+
res.on("end", () => {
|
|
2466
|
+
try {
|
|
2467
|
+
const value = JSON.parse(body).value || null;
|
|
2468
|
+
if (!value) {
|
|
2469
|
+
log6.warn(`[screenshot] Empty response from ${endpoint}`);
|
|
256
2470
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const steps = pickle.steps ?? [];
|
|
263
|
-
// Parse line numbers and keywords for TestLens navigation and step labels
|
|
264
|
-
const { featureLine, scenarioLine, stepLines, stepKeywords } = parseCucumberScenario(featureContent, scenarioName, steps.map((s) => s.text));
|
|
265
|
-
if (featureAbsPath && featureLine > 0) {
|
|
266
|
-
featureSuite.callSource = `${featureAbsPath}:${featureLine}`;
|
|
267
|
-
}
|
|
268
|
-
// Create a scenario sub-suite (child of feature suite).
|
|
269
|
-
// Use deterministicUid (no counter) so the SAME scenario always maps to the
|
|
270
|
-
// SAME uid across retries, enabling correct retry detection below.
|
|
271
|
-
const scenarioUid = deterministicUid(featureUri, `scenario:${scenarioName}`);
|
|
272
|
-
const scenarioSuite = {
|
|
273
|
-
uid: scenarioUid,
|
|
274
|
-
cid: DEFAULTS.CID,
|
|
275
|
-
title: scenarioName,
|
|
276
|
-
fullTitle: `${featureName} ${scenarioName}`,
|
|
277
|
-
parent: featureSuite.uid,
|
|
278
|
-
type: 'suite',
|
|
279
|
-
file: featureUri,
|
|
280
|
-
start: new Date(),
|
|
281
|
-
state: 'running',
|
|
282
|
-
end: null,
|
|
283
|
-
tests: [],
|
|
284
|
-
suites: [],
|
|
285
|
-
hooks: [],
|
|
286
|
-
_duration: 0,
|
|
287
|
-
callSource: featureAbsPath && scenarioLine > 0
|
|
288
|
-
? `${featureAbsPath}:${scenarioLine}`
|
|
289
|
-
: undefined
|
|
290
|
-
};
|
|
291
|
-
// Create a TestStats entry for each step
|
|
292
|
-
steps.forEach((step, i) => {
|
|
293
|
-
const keyword = stepKeywords[i] || '';
|
|
294
|
-
const stepLabel = keyword ? `${keyword} ${step.text}` : step.text;
|
|
295
|
-
const stepUid = deterministicUid(featureUri, `step:${scenarioName}:${step.text}`);
|
|
296
|
-
scenarioSuite.tests.push({
|
|
297
|
-
uid: stepUid,
|
|
298
|
-
cid: DEFAULTS.CID,
|
|
299
|
-
title: stepLabel,
|
|
300
|
-
fullTitle: `${scenarioName} ${stepLabel}`,
|
|
301
|
-
parent: scenarioUid,
|
|
302
|
-
state: 'pending',
|
|
303
|
-
start: new Date(),
|
|
304
|
-
end: null,
|
|
305
|
-
type: 'test',
|
|
306
|
-
file: featureUri,
|
|
307
|
-
retries: 0,
|
|
308
|
-
_duration: 0,
|
|
309
|
-
hooks: [],
|
|
310
|
-
callSource: (() => {
|
|
311
|
-
const loc = findStepDefinitionLine(stepDefFiles, step.text);
|
|
312
|
-
if (loc) {
|
|
313
|
-
return `${loc.filePath}:${loc.line}`;
|
|
314
|
-
}
|
|
315
|
-
if (featureAbsPath && stepLines[i] > 0) {
|
|
316
|
-
return `${featureAbsPath}:${stepLines[i]}`;
|
|
317
|
-
}
|
|
318
|
-
return undefined;
|
|
319
|
-
})()
|
|
320
|
-
});
|
|
2471
|
+
resolve2(value);
|
|
2472
|
+
} catch {
|
|
2473
|
+
log6.warn(`[screenshot] Failed to parse response from ${endpoint}`);
|
|
2474
|
+
resolve2(null);
|
|
2475
|
+
}
|
|
321
2476
|
});
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.testReporter.updateSuites();
|
|
344
|
-
if (!this.isScriptInjected) {
|
|
345
|
-
this.browserProxy.wrapUrlMethod(browser);
|
|
346
|
-
this.isScriptInjected = true;
|
|
347
|
-
}
|
|
348
|
-
this.browserProxy.wrapBrowserCommands(browser);
|
|
349
|
-
this.browserProxy.resetCommandTracking();
|
|
350
|
-
log.info(`🥒 Scenario: ${scenarioName}`);
|
|
2477
|
+
});
|
|
2478
|
+
req.on("error", (err) => {
|
|
2479
|
+
log6.warn(
|
|
2480
|
+
`[screenshot] HTTP request failed (${endpoint}): ${errorMessage(err)}`
|
|
2481
|
+
);
|
|
2482
|
+
resolve2(null);
|
|
2483
|
+
});
|
|
2484
|
+
req.setTimeout(5e3, () => {
|
|
2485
|
+
log6.warn(`[screenshot] Request timed out (${endpoint})`);
|
|
2486
|
+
req.destroy();
|
|
2487
|
+
resolve2(null);
|
|
2488
|
+
});
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
onSourceReadError(filePath, err) {
|
|
2492
|
+
log6.warn(`Failed to read source file ${filePath}: ${errorMessage(err)}`);
|
|
2493
|
+
}
|
|
2494
|
+
onUpstreamDrop(event, reason, err) {
|
|
2495
|
+
if (reason === "send-error") {
|
|
2496
|
+
log6.warn(`[upstream] Failed to send "${event}": ${errorMessage(err)}`);
|
|
2497
|
+
return;
|
|
351
2498
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
try {
|
|
355
|
-
const status = String(result?.status ?? 'UNKNOWN').toUpperCase();
|
|
356
|
-
const scenarioState = status === 'PASSED'
|
|
357
|
-
? TEST_STATE.PASSED
|
|
358
|
-
: status === 'SKIPPED'
|
|
359
|
-
? TEST_STATE.SKIPPED
|
|
360
|
-
: TEST_STATE.FAILED;
|
|
361
|
-
if (this.#currentScenarioSuite) {
|
|
362
|
-
const duration = Date.now() -
|
|
363
|
-
(this.#currentScenarioSuite.start?.getTime() ?? Date.now());
|
|
364
|
-
this.#currentScenarioSuite.state = scenarioState;
|
|
365
|
-
this.#currentScenarioSuite.end = new Date();
|
|
366
|
-
this.#currentScenarioSuite._duration = duration;
|
|
367
|
-
// Ensure any still-running or pending steps are marked appropriately
|
|
368
|
-
for (const step of this.#currentScenarioSuite.tests) {
|
|
369
|
-
if (typeof step !== 'string' &&
|
|
370
|
-
(step.state === 'running' || step.state === 'pending')) {
|
|
371
|
-
step.state =
|
|
372
|
-
scenarioState === TEST_STATE.PASSED
|
|
373
|
-
? TEST_STATE.PASSED
|
|
374
|
-
: TEST_STATE.FAILED;
|
|
375
|
-
step.end = new Date();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
const featureUri = pickle?.uri ?? 'unknown.feature';
|
|
379
|
-
this.testManager.markTestAsProcessed(featureUri, pickle?.name ?? '');
|
|
380
|
-
const featureSuite = this.suiteManager.getSuite(featureUri);
|
|
381
|
-
if (featureSuite) {
|
|
382
|
-
// Finalize is not called until all scenarios are done — just update state
|
|
383
|
-
this.suiteManager.finalizeSuiteState(featureSuite);
|
|
384
|
-
}
|
|
385
|
-
this.#incrementCount(scenarioState);
|
|
386
|
-
const icon = this.#testIcon(scenarioState);
|
|
387
|
-
const durationSec = (duration / 1000).toFixed(2);
|
|
388
|
-
log.info(` ${icon} ${pickle?.name ?? 'Unknown'} (${durationSec}s)`);
|
|
389
|
-
this.testReporter.updateSuites();
|
|
390
|
-
this.#currentScenarioSuite = null;
|
|
391
|
-
this.#currentStep = null;
|
|
392
|
-
this.#currentTest = null;
|
|
393
|
-
}
|
|
394
|
-
await this.sessionCapturer.captureTrace(browser);
|
|
395
|
-
}
|
|
396
|
-
catch (err) {
|
|
397
|
-
log.error(`Failed to finalize Cucumber scenario: ${err.message}`);
|
|
398
|
-
}
|
|
2499
|
+
if (this.hasEverConnected()) {
|
|
2500
|
+
log6.warn(`[upstream] WebSocket not open \u2014 dropping "${event}" event`);
|
|
399
2501
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Inject the WDIO devtools script into the browser page
|
|
2505
|
+
*/
|
|
2506
|
+
async injectScript(browser) {
|
|
2507
|
+
try {
|
|
2508
|
+
const scriptContent = await loadInjectableScript();
|
|
2509
|
+
const injectionScript = `
|
|
2510
|
+
const script = document.createElement('script');
|
|
2511
|
+
script.textContent = arguments[0];
|
|
2512
|
+
document.head.appendChild(script);
|
|
2513
|
+
return true;
|
|
2514
|
+
`;
|
|
2515
|
+
await browser.execute(injectionScript, [scriptContent]);
|
|
2516
|
+
const hasCollector = await pollUntilReady(async () => {
|
|
2517
|
+
const checkResult = await browser.execute(
|
|
2518
|
+
'return typeof window.wdioTraceCollector !== "undefined"'
|
|
2519
|
+
);
|
|
2520
|
+
return unwrapDriverValue(checkResult) === true;
|
|
2521
|
+
});
|
|
2522
|
+
if (hasCollector) {
|
|
2523
|
+
log6.info("\u2713 Script injected and collector ready");
|
|
2524
|
+
} else {
|
|
2525
|
+
log6.warn("Script injection may have failed \u2014 collector not found");
|
|
2526
|
+
}
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
log6.error(`Failed to inject script: ${errorMessage(err)}`);
|
|
2529
|
+
throw err;
|
|
418
2530
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.testReporter.updateSuites();
|
|
436
|
-
void pickleStep; // used by BeforeStep to find the step
|
|
437
|
-
}
|
|
438
|
-
async beforeEach(browser) {
|
|
439
|
-
if (this.#isCucumberRunner) {
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
await this.#ensureSessionInitialized(browser);
|
|
443
|
-
const currentTest = browser.currentTest;
|
|
444
|
-
if (!currentTest) {
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
const testFile = (currentTest.module || '').split('/').pop() ||
|
|
448
|
-
currentTest.module ||
|
|
449
|
-
DEFAULTS.FILE_NAME;
|
|
450
|
-
let fullPath = findTestFileFromStack() || null;
|
|
451
|
-
const cachedPath = this.browserProxy.getCurrentTestFullPath();
|
|
452
|
-
if (!fullPath && cachedPath && cachedPath.includes(testFile)) {
|
|
453
|
-
fullPath = cachedPath;
|
|
454
|
-
}
|
|
455
|
-
if (!fullPath && testFile) {
|
|
456
|
-
const workspaceRoot = process.cwd();
|
|
457
|
-
// currentTest.module is the path relative to a src_folder, e.g. "basic/ecosia"
|
|
458
|
-
// So we must try: path.join(cwd, srcFolder, module + '.js') for each src_folder
|
|
459
|
-
const modulePath = (currentTest.module || '').replace(/\\/g, '/');
|
|
460
|
-
const srcFolderPaths = this.#srcFolders.flatMap((sf) => modulePath
|
|
461
|
-
? [
|
|
462
|
-
path.join(workspaceRoot, sf, modulePath + '.js'),
|
|
463
|
-
path.join(workspaceRoot, sf, modulePath + '.ts'),
|
|
464
|
-
path.join(workspaceRoot, sf, modulePath + '.cjs'),
|
|
465
|
-
path.join(workspaceRoot, sf, modulePath)
|
|
466
|
-
]
|
|
467
|
-
: []);
|
|
468
|
-
const possiblePaths = [
|
|
469
|
-
// Highest priority: expand module path via each configured src_folder
|
|
470
|
-
...srcFolderPaths,
|
|
471
|
-
// Fallback: treat module path as relative to cwd (works when src_folders isn't nested)
|
|
472
|
-
...(modulePath
|
|
473
|
-
? [
|
|
474
|
-
path.join(workspaceRoot, modulePath + '.js'),
|
|
475
|
-
path.join(workspaceRoot, modulePath + '.ts'),
|
|
476
|
-
path.join(workspaceRoot, modulePath + '.cjs'),
|
|
477
|
-
path.join(workspaceRoot, modulePath)
|
|
478
|
-
]
|
|
479
|
-
: []),
|
|
480
|
-
path.join(workspaceRoot, 'example/tests', testFile + '.js'),
|
|
481
|
-
path.join(workspaceRoot, 'example/tests', testFile),
|
|
482
|
-
path.join(workspaceRoot, 'tests', testFile + '.js'),
|
|
483
|
-
path.join(workspaceRoot, 'test', testFile + '.js'),
|
|
484
|
-
path.join(workspaceRoot, testFile + '.js')
|
|
485
|
-
];
|
|
486
|
-
for (const possiblePath of possiblePaths) {
|
|
487
|
-
if (fs.existsSync(possiblePath)) {
|
|
488
|
-
fullPath = possiblePath;
|
|
489
|
-
break;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
// Extract suite title and test metadata
|
|
494
|
-
let suiteTitle = testFile;
|
|
495
|
-
if (!fullPath) {
|
|
496
|
-
log.warn(`[beforeEach] Could not resolve file path for "${testFile}" — source view will be unavailable`);
|
|
497
|
-
}
|
|
498
|
-
let testNames = [];
|
|
499
|
-
let suiteLine = null;
|
|
500
|
-
let testLines = [];
|
|
501
|
-
if (fullPath) {
|
|
502
|
-
const { suiteTitle: parsedTitle, testNames: parsedNames, suiteLine: parsedSuiteLine, testLines: parsedTestLines } = extractTestMetadata(fullPath);
|
|
503
|
-
if (parsedTitle) {
|
|
504
|
-
suiteTitle = parsedTitle;
|
|
505
|
-
}
|
|
506
|
-
testNames = parsedNames;
|
|
507
|
-
suiteLine = parsedSuiteLine;
|
|
508
|
-
testLines = parsedTestLines;
|
|
509
|
-
}
|
|
510
|
-
const rerunLabel = this.#getRerunLabel();
|
|
511
|
-
if (rerunLabel) {
|
|
512
|
-
const targetIndex = testNames.findIndex((name) => name === rerunLabel);
|
|
513
|
-
if (targetIndex !== -1) {
|
|
514
|
-
testNames = [testNames[targetIndex]];
|
|
515
|
-
testLines = testLines[targetIndex] ? [testLines[targetIndex]] : [];
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// Get or create suite for this test file
|
|
519
|
-
const currentSuite = this.suiteManager.getOrCreateSuite(testFile, suiteTitle, fullPath, testNames, suiteLine, testLines);
|
|
520
|
-
// Capture source file for display
|
|
521
|
-
if (fullPath && fullPath.includes('/')) {
|
|
522
|
-
this.sessionCapturer.captureSource(fullPath).catch(() => { });
|
|
523
|
-
}
|
|
524
|
-
const runningTest = currentSuite.tests.find((t) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING);
|
|
525
|
-
if (runningTest) {
|
|
526
|
-
const testcases = currentTest?.results?.testcases || {};
|
|
527
|
-
if (testcases[runningTest.title]) {
|
|
528
|
-
const testcase = testcases[runningTest.title];
|
|
529
|
-
const testState = determineTestState(testcase);
|
|
530
|
-
runningTest.state = testState;
|
|
531
|
-
runningTest.end = new Date();
|
|
532
|
-
runningTest._duration = parseFloat(testcase.time || '0') * 1000;
|
|
533
|
-
this.testManager.updateTestState(runningTest, testState);
|
|
534
|
-
this.testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
535
|
-
this.#incrementCount(testState);
|
|
536
|
-
const prevIcon = this.#testIcon(testState);
|
|
537
|
-
log.info(` ${prevIcon} ${runningTest.title} (${(runningTest._duration / 1000).toFixed(2)}s)`);
|
|
538
|
-
}
|
|
539
|
-
else {
|
|
540
|
-
const endTime = new Date();
|
|
541
|
-
const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
|
|
542
|
-
this.testManager.updateTestState(runningTest, TEST_STATE.PASSED, endTime, duration);
|
|
543
|
-
this.testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
544
|
-
this.#passCount++;
|
|
545
|
-
log.info(` ✅ ${runningTest.title} (${(duration / 1000).toFixed(2)}s)`);
|
|
546
|
-
}
|
|
547
|
-
await new Promise((resolve) => setTimeout(resolve, TIMING.UI_RENDER_DELAY));
|
|
548
|
-
}
|
|
549
|
-
const processedTests = this.testManager.getProcessedTests(testFile);
|
|
550
|
-
const runtimeTestName = typeof currentTest?.name === 'string'
|
|
551
|
-
? currentTest.name.trim()
|
|
552
|
-
: undefined;
|
|
553
|
-
const matchedRuntimeTestName = runtimeTestName
|
|
554
|
-
? testNames.find((name) => runtimeTestName === name || runtimeTestName.endsWith(` ${name}`))
|
|
555
|
-
: undefined;
|
|
556
|
-
const currentTestName = matchedRuntimeTestName ||
|
|
557
|
-
testNames.find((name) => !processedTests.has(name));
|
|
558
|
-
if (currentTestName) {
|
|
559
|
-
if (processedTests.size === 0) {
|
|
560
|
-
this.suiteManager.markSuiteAsRunning(currentSuite);
|
|
561
|
-
}
|
|
562
|
-
const test = this.testManager.findTestInSuite(currentSuite, currentTestName);
|
|
563
|
-
if (test) {
|
|
564
|
-
test.state = TEST_STATE.RUNNING;
|
|
565
|
-
test.start = new Date();
|
|
566
|
-
test.end = null;
|
|
567
|
-
this.testReporter.onTestStart(test);
|
|
568
|
-
this.#currentTest = test;
|
|
569
|
-
log.info(` ▶ ${currentTestName}`);
|
|
570
|
-
await new Promise((resolve) => setTimeout(resolve, TIMING.TEST_START_DELAY));
|
|
571
|
-
}
|
|
572
|
-
else {
|
|
573
|
-
log.warn(`Test "${currentTestName}" not found in suite "${currentSuite.title}"`);
|
|
574
|
-
this.#currentTest = null;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (!this.isScriptInjected) {
|
|
578
|
-
this.browserProxy.wrapUrlMethod(browser);
|
|
579
|
-
this.isScriptInjected = true;
|
|
580
|
-
}
|
|
581
|
-
this.browserProxy.resetCommandTracking();
|
|
582
|
-
this.browserProxy.wrapBrowserCommands(browser);
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Capture Chrome DevTools browser console logs via WebDriver log API.
|
|
2534
|
+
* Requires loggingPrefs: { browser: 'ALL' } in Chrome capabilities.
|
|
2535
|
+
*/
|
|
2536
|
+
async captureBrowserLogs(browser) {
|
|
2537
|
+
try {
|
|
2538
|
+
const rawLogs = await browser.getLog("browser");
|
|
2539
|
+
const logs = unwrapDriverValue(rawLogs);
|
|
2540
|
+
if (!Array.isArray(logs) || logs.length === 0) {
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const entries = mapChromeBrowserLogs(logs);
|
|
2544
|
+
this.consoleLogs.push(...entries);
|
|
2545
|
+
this.sendUpstream("consoleLogs", entries);
|
|
2546
|
+
} catch {
|
|
583
2547
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const results = currentTest?.results || {};
|
|
593
|
-
const testFile = (currentTest.module || '').split('/').pop() || DEFAULTS.FILE_NAME;
|
|
594
|
-
const testcases = results.testcases || {};
|
|
595
|
-
const testcaseNames = Object.keys(testcases);
|
|
596
|
-
const currentSuite = this.suiteManager.getSuite(testFile);
|
|
597
|
-
if (currentSuite) {
|
|
598
|
-
const processedTests = this.testManager.getProcessedTests(testFile);
|
|
599
|
-
if (testcaseNames.length === 0) {
|
|
600
|
-
const runningTest = currentSuite.tests.find((t) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING);
|
|
601
|
-
if (runningTest && !processedTests.has(runningTest.title)) {
|
|
602
|
-
const testState = results.errors > 0 || results.failed > 0
|
|
603
|
-
? TEST_STATE.FAILED
|
|
604
|
-
: TEST_STATE.PASSED;
|
|
605
|
-
const endTime = new Date();
|
|
606
|
-
const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
|
|
607
|
-
this.testManager.updateTestState(runningTest, testState, endTime, duration);
|
|
608
|
-
this.testManager.markTestAsProcessed(testFile, runningTest.title);
|
|
609
|
-
this.#incrementCount(testState);
|
|
610
|
-
const icon = this.#testIcon(testState);
|
|
611
|
-
log.info(` ${icon} ${runningTest.title} (${(duration / 1000).toFixed(2)}s)`);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
else {
|
|
615
|
-
const unprocessedTests = testcaseNames.filter((name) => !processedTests.has(name));
|
|
616
|
-
for (const currentTestName of unprocessedTests) {
|
|
617
|
-
const testcase = testcases[currentTestName];
|
|
618
|
-
const testState = determineTestState(testcase);
|
|
619
|
-
const test = this.testManager.findTestInSuite(currentSuite, currentTestName);
|
|
620
|
-
if (test) {
|
|
621
|
-
const dur = parseFloat(testcase.time || '0') * 1000;
|
|
622
|
-
this.testManager.updateTestState(test, testState, new Date(), dur);
|
|
623
|
-
this.#incrementCount(testState);
|
|
624
|
-
const icon = this.#testIcon(testState);
|
|
625
|
-
log.info(` ${icon} ${currentTestName} (${(dur / 1000).toFixed(2)}s)`);
|
|
626
|
-
}
|
|
627
|
-
this.testManager.markTestAsProcessed(testFile, currentTestName);
|
|
628
|
-
}
|
|
629
|
-
if (processedTests.size === testcaseNames.length) {
|
|
630
|
-
this.suiteManager.finalizeSuite(currentSuite);
|
|
631
|
-
await new Promise((resolve) => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY));
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
await this.sessionCapturer.captureTrace(browser);
|
|
636
|
-
}
|
|
637
|
-
catch (err) {
|
|
638
|
-
log.error(`Failed to capture trace: ${err.message}`);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Parse Chrome performance logs to extract network request entries.
|
|
2551
|
+
* Requires loggingPrefs: { performance: 'ALL' } in Chrome capabilities.
|
|
2552
|
+
*/
|
|
2553
|
+
async captureNetworkFromPerformanceLogs(browser) {
|
|
2554
|
+
if (this.bidiActive) {
|
|
2555
|
+
return;
|
|
641
2556
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
catch {
|
|
688
|
-
if (!exitBySignal) {
|
|
689
|
-
log.info('Browser window closed, stopping DevTools app');
|
|
690
|
-
break;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
if (!exitBySignal) {
|
|
695
|
-
process.removeListener('SIGINT', signalHandler);
|
|
696
|
-
process.removeListener('SIGTERM', signalHandler);
|
|
697
|
-
logger.setLevel('devtools', 'info');
|
|
698
|
-
try {
|
|
699
|
-
await this.#devtoolsBrowser.deleteSession();
|
|
700
|
-
}
|
|
701
|
-
catch {
|
|
702
|
-
// session already closed
|
|
703
|
-
}
|
|
704
|
-
await stop();
|
|
705
|
-
process.exit(0);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
2557
|
+
try {
|
|
2558
|
+
const rawLogs = await browser.getLog("performance");
|
|
2559
|
+
const logs = unwrapDriverValue(rawLogs);
|
|
2560
|
+
if (!Array.isArray(logs) || logs.length === 0) {
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
const networkEntries = parseNetworkFromPerfLogs(logs);
|
|
2564
|
+
if (networkEntries.length === 0) {
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const deduped = dedupeNetworkRequests(
|
|
2568
|
+
networkEntries,
|
|
2569
|
+
this.networkRequests
|
|
2570
|
+
);
|
|
2571
|
+
if (deduped.length > 0) {
|
|
2572
|
+
const normalized = deduped.map((d) => ({
|
|
2573
|
+
...d,
|
|
2574
|
+
type: d.type ?? "unknown"
|
|
2575
|
+
}));
|
|
2576
|
+
this.networkRequests.push(...normalized);
|
|
2577
|
+
this.sendUpstream("networkRequests", normalized);
|
|
2578
|
+
}
|
|
2579
|
+
} catch (err) {
|
|
2580
|
+
const msg = errorMessage(err) ?? "";
|
|
2581
|
+
if (!msg.includes("log type") && !msg.includes("performance")) {
|
|
2582
|
+
log6.warn(`Performance log capture failed: ${msg}`);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Capture trace data from the browser (network requests, console logs, etc.)
|
|
2588
|
+
*/
|
|
2589
|
+
async captureTrace(browser) {
|
|
2590
|
+
await this.captureNetworkFromPerformanceLogs(browser);
|
|
2591
|
+
try {
|
|
2592
|
+
const checkResult = await browser.execute(
|
|
2593
|
+
'return typeof window.wdioTraceCollector !== "undefined"'
|
|
2594
|
+
);
|
|
2595
|
+
const collectorExists = unwrapDriverValue(checkResult) === true;
|
|
2596
|
+
if (!collectorExists) {
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
const result = await browser.execute(`
|
|
2600
|
+
if (typeof window.wdioTraceCollector === 'undefined') {
|
|
2601
|
+
return null;
|
|
708
2602
|
}
|
|
709
|
-
|
|
710
|
-
|
|
2603
|
+
return window.wdioTraceCollector.getTraceData();
|
|
2604
|
+
`);
|
|
2605
|
+
const traceData = unwrapDriverValue(
|
|
2606
|
+
result
|
|
2607
|
+
);
|
|
2608
|
+
if (!traceData) {
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
this.processTracePayload(traceData);
|
|
2612
|
+
const mutationCount = Array.isArray(
|
|
2613
|
+
traceData.mutations
|
|
2614
|
+
) ? traceData.mutations.length : 0;
|
|
2615
|
+
if (mutationCount > 0) {
|
|
2616
|
+
log6.info(`[trace] Captured ${mutationCount} DOM mutation(s)`);
|
|
2617
|
+
}
|
|
2618
|
+
} catch (err) {
|
|
2619
|
+
log6.error(
|
|
2620
|
+
`Failed to capture trace from injected script: ${errorMessage(err)}`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
// src/reporter.ts
|
|
2627
|
+
import logger7 from "@wdio/logger";
|
|
2628
|
+
var log7 = logger7("@wdio/nightwatch-devtools:Reporter");
|
|
2629
|
+
var TestReporter = class extends TestReporterBase {
|
|
2630
|
+
#currentSpecFile;
|
|
2631
|
+
#testNamesCache = /* @__PURE__ */ new Map();
|
|
2632
|
+
#currentSuite;
|
|
2633
|
+
onSuiteStart(suiteStats) {
|
|
2634
|
+
this.#currentSpecFile = suiteStats.file;
|
|
2635
|
+
this.#currentSuite = suiteStats;
|
|
2636
|
+
const rerunLabel = process.env[REUSE_ENV.RERUN_ENTRY_TYPE] === "test" ? process.env[REUSE_ENV.RERUN_LABEL]?.trim() : void 0;
|
|
2637
|
+
if (!suiteStats.uid) {
|
|
2638
|
+
suiteStats.uid = generateStableUid2(suiteStats);
|
|
2639
|
+
}
|
|
2640
|
+
if (this.#currentSpecFile && !this.#testNamesCache.has(this.#currentSpecFile)) {
|
|
2641
|
+
const metadata = extractTestMetadata(this.#currentSpecFile);
|
|
2642
|
+
const testNames = rerunLabel ? metadata.testNames.filter((name) => name === rerunLabel) : metadata.testNames;
|
|
2643
|
+
if (testNames.length > 0) {
|
|
2644
|
+
this.#testNamesCache.set(this.#currentSpecFile, testNames);
|
|
2645
|
+
log7.info(
|
|
2646
|
+
`\u{1F4DD} Extracted ${testNames.length} test names from ${this.#currentSpecFile}`
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
this.allSuites.push(suiteStats);
|
|
2651
|
+
this.sendUpstream();
|
|
2652
|
+
}
|
|
2653
|
+
clearExecutionData() {
|
|
2654
|
+
super.clearExecutionData();
|
|
2655
|
+
this.#testNamesCache.clear();
|
|
2656
|
+
this.#currentSuite = void 0;
|
|
2657
|
+
this.#currentSpecFile = void 0;
|
|
2658
|
+
}
|
|
2659
|
+
getCurrentSuite() {
|
|
2660
|
+
return this.#currentSuite;
|
|
2661
|
+
}
|
|
2662
|
+
/** Find by title within parent suite — Nightwatch retries reuse the title slot. */
|
|
2663
|
+
onTestStart(testStats) {
|
|
2664
|
+
if (!testStats.uid || testStats.uid.includes("temp-")) {
|
|
2665
|
+
testStats.uid = generateStableUid2(testStats);
|
|
2666
|
+
}
|
|
2667
|
+
for (const suite of this.allSuites) {
|
|
2668
|
+
const testIndex = suite.tests.findIndex((t) => {
|
|
2669
|
+
if (typeof t === "string") {
|
|
2670
|
+
return false;
|
|
711
2671
|
}
|
|
2672
|
+
return t.title === testStats.title && t.parent === suite.uid;
|
|
2673
|
+
});
|
|
2674
|
+
if (testIndex !== -1) {
|
|
2675
|
+
suite.tests[testIndex] = testStats;
|
|
2676
|
+
this.sendUpstream();
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
712
2679
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
2680
|
+
if (this.#currentSuite) {
|
|
2681
|
+
this.#currentSuite.tests.push(testStats);
|
|
2682
|
+
}
|
|
2683
|
+
this.sendUpstream();
|
|
2684
|
+
}
|
|
2685
|
+
/** Synthesize `skipped` entries for tests that never executed. */
|
|
2686
|
+
onSuiteEnd(suiteStats) {
|
|
2687
|
+
const cachedNames = this.#testNamesCache.get(suiteStats.file) || [];
|
|
2688
|
+
const processedTestNames = new Set(
|
|
2689
|
+
suiteStats.tests.map((t) => typeof t === "string" ? t : t.title).filter((title) => Boolean(title))
|
|
2690
|
+
);
|
|
2691
|
+
cachedNames.forEach((testName) => {
|
|
2692
|
+
if (!processedTestNames.has(testName)) {
|
|
2693
|
+
const skippedTest = {
|
|
2694
|
+
uid: generateStableUid2({
|
|
2695
|
+
file: suiteStats.file,
|
|
2696
|
+
fullTitle: `${suiteStats.title} ${testName}`
|
|
2697
|
+
}),
|
|
2698
|
+
cid: DEFAULTS.CID,
|
|
2699
|
+
title: testName,
|
|
2700
|
+
fullTitle: `${suiteStats.title} ${testName}`,
|
|
2701
|
+
parent: suiteStats.uid,
|
|
2702
|
+
state: "skipped",
|
|
2703
|
+
start: /* @__PURE__ */ new Date(),
|
|
2704
|
+
end: /* @__PURE__ */ new Date(),
|
|
2705
|
+
type: "test",
|
|
2706
|
+
file: suiteStats.file,
|
|
2707
|
+
retries: DEFAULTS.RETRIES,
|
|
2708
|
+
_duration: DEFAULTS.DURATION,
|
|
2709
|
+
hooks: []
|
|
723
2710
|
};
|
|
2711
|
+
suiteStats.tests.push(skippedTest);
|
|
2712
|
+
log7.info(`Created skipped test "${testName}" (never executed)`);
|
|
2713
|
+
}
|
|
2714
|
+
});
|
|
2715
|
+
this.sendUpstream();
|
|
2716
|
+
}
|
|
2717
|
+
/** Replace a suite when its UID changes mid-run (after spec rescan). */
|
|
2718
|
+
updateSuite(suiteStats) {
|
|
2719
|
+
const index = this.allSuites.findIndex((s) => s.file === suiteStats.file);
|
|
2720
|
+
if (index !== -1) {
|
|
2721
|
+
this.allSuites.splice(index, 1);
|
|
2722
|
+
}
|
|
2723
|
+
this.allSuites.push(suiteStats);
|
|
2724
|
+
this.#currentSuite = suiteStats;
|
|
2725
|
+
this.sendUpstream();
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
|
|
2729
|
+
// src/helpers/testManager.ts
|
|
2730
|
+
var TestManager = class {
|
|
2731
|
+
constructor(testReporter) {
|
|
2732
|
+
this.testReporter = testReporter;
|
|
2733
|
+
}
|
|
2734
|
+
testReporter;
|
|
2735
|
+
processedTests = /* @__PURE__ */ new Map();
|
|
2736
|
+
lastKnownTestName = null;
|
|
2737
|
+
/**
|
|
2738
|
+
* Update test state and report to UI
|
|
2739
|
+
*/
|
|
2740
|
+
updateTestState(test, state, endTime, duration) {
|
|
2741
|
+
test.state = state;
|
|
2742
|
+
if (endTime) {
|
|
2743
|
+
test.end = endTime;
|
|
2744
|
+
}
|
|
2745
|
+
if (duration !== void 0) {
|
|
2746
|
+
test._duration = duration;
|
|
724
2747
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
2748
|
+
if (state === TEST_STATE.PASSED) {
|
|
2749
|
+
this.testReporter.onTestEnd(test);
|
|
2750
|
+
} else if (state === TEST_STATE.FAILED) {
|
|
2751
|
+
this.testReporter.onTestEnd(test);
|
|
2752
|
+
} else if (state === TEST_STATE.RUNNING) {
|
|
2753
|
+
this.testReporter.onTestStart(test);
|
|
2754
|
+
}
|
|
2755
|
+
if (state !== TEST_STATE.RUNNING) {
|
|
2756
|
+
this.testReporter.onTestEnd(test);
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Find test in suite by title
|
|
2761
|
+
*/
|
|
2762
|
+
findTestInSuite(suite, testTitle) {
|
|
2763
|
+
return suite.tests.find(
|
|
2764
|
+
(t) => typeof t !== "string" && t.title === testTitle
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Mark test as processed to avoid duplicate reporting
|
|
2769
|
+
*/
|
|
2770
|
+
markTestAsProcessed(testFile, testTitle) {
|
|
2771
|
+
if (!this.processedTests.has(testFile)) {
|
|
2772
|
+
this.processedTests.set(testFile, /* @__PURE__ */ new Set());
|
|
2773
|
+
}
|
|
2774
|
+
this.processedTests.get(testFile).add(testTitle);
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Check if test has been processed
|
|
2778
|
+
*/
|
|
2779
|
+
isTestProcessed(testFile, testTitle) {
|
|
2780
|
+
return this.processedTests.get(testFile)?.has(testTitle) ?? false;
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Get processed tests for a file
|
|
2784
|
+
*/
|
|
2785
|
+
getProcessedTests(testFile) {
|
|
2786
|
+
return this.processedTests.get(testFile) || /* @__PURE__ */ new Set();
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Detect test boundary and finalize previous test if needed
|
|
2790
|
+
* Returns the current test name
|
|
2791
|
+
*/
|
|
2792
|
+
detectTestBoundary(currentNightwatchTest) {
|
|
2793
|
+
const currentTestName = currentNightwatchTest?.name || DEFAULTS.TEST_NAME;
|
|
2794
|
+
if (this.lastKnownTestName && currentTestName !== this.lastKnownTestName && currentTestName !== DEFAULTS.TEST_NAME) {
|
|
2795
|
+
const testFile = currentNightwatchTest.module?.split("/").pop() || DEFAULTS.FILE_NAME;
|
|
2796
|
+
const currentSuite = this.testReporter.getCurrentSuite();
|
|
2797
|
+
if (currentSuite) {
|
|
2798
|
+
const prevTest = this.findTestInSuite(
|
|
2799
|
+
currentSuite,
|
|
2800
|
+
this.lastKnownTestName
|
|
2801
|
+
);
|
|
2802
|
+
if (prevTest && prevTest.state === TEST_STATE.RUNNING) {
|
|
2803
|
+
const prevTestCase = currentNightwatchTest.results?.testcases?.[this.lastKnownTestName];
|
|
2804
|
+
if (prevTestCase) {
|
|
2805
|
+
const testState = determineTestState(prevTestCase);
|
|
2806
|
+
this.updateTestState(
|
|
2807
|
+
prevTest,
|
|
2808
|
+
testState,
|
|
2809
|
+
/* @__PURE__ */ new Date(),
|
|
2810
|
+
parseFloat(prevTestCase.time || "0") * 1e3
|
|
2811
|
+
);
|
|
2812
|
+
this.markTestAsProcessed(testFile, this.lastKnownTestName);
|
|
2813
|
+
}
|
|
728
2814
|
}
|
|
729
|
-
|
|
730
|
-
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
if (currentTestName !== DEFAULTS.TEST_NAME) {
|
|
2818
|
+
this.lastKnownTestName = currentTestName;
|
|
2819
|
+
}
|
|
2820
|
+
return currentTestName;
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Start a pending test if this is its first command
|
|
2824
|
+
*/
|
|
2825
|
+
startTestIfPending(currentTestName) {
|
|
2826
|
+
if (currentTestName === DEFAULTS.TEST_NAME || this.lastKnownTestName !== currentTestName) {
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
const currentSuite = this.testReporter.getCurrentSuite();
|
|
2830
|
+
if (!currentSuite) {
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
const test = this.findTestInSuite(currentSuite, currentTestName);
|
|
2834
|
+
if (test && test.state === TEST_STATE.PENDING) {
|
|
2835
|
+
test.start = /* @__PURE__ */ new Date();
|
|
2836
|
+
test.end = null;
|
|
2837
|
+
this.updateTestState(test, TEST_STATE.RUNNING);
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Finalize all incomplete tests in a suite
|
|
2842
|
+
*/
|
|
2843
|
+
finalizeSuiteTests(suite, testcases) {
|
|
2844
|
+
suite.tests.forEach((entry) => {
|
|
2845
|
+
if (typeof entry === "string") {
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const test = entry;
|
|
2849
|
+
if (test.state === TEST_STATE.RUNNING && test.start) {
|
|
2850
|
+
test.state = TEST_STATE.PASSED;
|
|
2851
|
+
stampTestEnd(test);
|
|
2852
|
+
this.updateTestState(test, TEST_STATE.PASSED);
|
|
2853
|
+
} else if (test.state === TEST_STATE.PENDING) {
|
|
2854
|
+
const testcase = testcases[test.title];
|
|
2855
|
+
if (testcase) {
|
|
2856
|
+
const testState = determineTestState(testcase);
|
|
2857
|
+
test.start = test.start || /* @__PURE__ */ new Date();
|
|
2858
|
+
this.updateTestState(
|
|
2859
|
+
test,
|
|
2860
|
+
testState,
|
|
2861
|
+
/* @__PURE__ */ new Date(),
|
|
2862
|
+
parseFloat(testcase.time || "0") * 1e3
|
|
2863
|
+
);
|
|
2864
|
+
} else {
|
|
2865
|
+
this.updateTestState(
|
|
2866
|
+
test,
|
|
2867
|
+
TEST_STATE.SKIPPED,
|
|
2868
|
+
/* @__PURE__ */ new Date(),
|
|
2869
|
+
0
|
|
2870
|
+
);
|
|
731
2871
|
}
|
|
732
|
-
|
|
733
|
-
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Reset internal state for new test files
|
|
2877
|
+
*/
|
|
2878
|
+
reset() {
|
|
2879
|
+
this.lastKnownTestName = null;
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
// src/helpers/suiteManager.ts
|
|
2884
|
+
var SuiteManager = class {
|
|
2885
|
+
constructor(testReporter) {
|
|
2886
|
+
this.testReporter = testReporter;
|
|
2887
|
+
}
|
|
2888
|
+
testReporter;
|
|
2889
|
+
currentSuiteByFile = /* @__PURE__ */ new Map();
|
|
2890
|
+
/**
|
|
2891
|
+
* Clear execution data when a rerun starts.
|
|
2892
|
+
* Resets all cached suites so they're recreated fresh during the new run.
|
|
2893
|
+
*/
|
|
2894
|
+
clearExecutionData() {
|
|
2895
|
+
this.currentSuiteByFile.clear();
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Get or create suite for a test file
|
|
2899
|
+
*/
|
|
2900
|
+
getOrCreateSuite(testFile, suiteTitle, fullPath, testNames, suiteLine, testLines) {
|
|
2901
|
+
if (!this.currentSuiteByFile.has(testFile)) {
|
|
2902
|
+
const file = fullPath || testFile;
|
|
2903
|
+
const suiteStats = createSuiteStats({
|
|
2904
|
+
uid: generateStableUid2(file, suiteTitle),
|
|
2905
|
+
cid: DEFAULTS.CID,
|
|
2906
|
+
title: suiteTitle,
|
|
2907
|
+
file,
|
|
2908
|
+
state: TEST_STATE.PENDING,
|
|
2909
|
+
callSource: suiteLine && fullPath ? `${fullPath}:${suiteLine}` : void 0
|
|
2910
|
+
});
|
|
2911
|
+
for (let idx = 0; idx < testNames.length; idx++) {
|
|
2912
|
+
const testName = testNames[idx];
|
|
2913
|
+
const testLine = testLines?.[idx];
|
|
2914
|
+
const fullTitle = `${suiteTitle} ${testName}`;
|
|
2915
|
+
const testEntry = createTestStats({
|
|
2916
|
+
uid: generateStableUid2(file, fullTitle),
|
|
2917
|
+
cid: DEFAULTS.CID,
|
|
2918
|
+
title: testName,
|
|
2919
|
+
fullTitle,
|
|
2920
|
+
file,
|
|
2921
|
+
parent: suiteStats.uid,
|
|
2922
|
+
state: TEST_STATE.PENDING,
|
|
2923
|
+
callSource: testLine && fullPath ? `${fullPath}:${testLine}` : void 0
|
|
2924
|
+
});
|
|
2925
|
+
suiteStats.tests.push(testEntry);
|
|
2926
|
+
}
|
|
2927
|
+
this.currentSuiteByFile.set(testFile, suiteStats);
|
|
2928
|
+
this.testReporter.onSuiteStart(suiteStats);
|
|
2929
|
+
}
|
|
2930
|
+
return this.currentSuiteByFile.get(testFile);
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Get suite for a test file
|
|
2934
|
+
*/
|
|
2935
|
+
getSuite(testFile) {
|
|
2936
|
+
return this.currentSuiteByFile.get(testFile);
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Mark suite as running.
|
|
2940
|
+
* Clears `end` so that the `finalizeSuite` guard (`if (suite.end) return`)
|
|
2941
|
+
* does not skip re-finalization during a rerun.
|
|
2942
|
+
*/
|
|
2943
|
+
markSuiteAsRunning(suite) {
|
|
2944
|
+
suite.state = TEST_STATE.RUNNING;
|
|
2945
|
+
suite.end = null;
|
|
2946
|
+
this.testReporter.updateSuites();
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Update suite state from current children without marking it as ended.
|
|
2950
|
+
* Used during Cucumber runs to keep the feature-level suite state fresh.
|
|
2951
|
+
*/
|
|
2952
|
+
finalizeSuiteState(suite) {
|
|
2953
|
+
suite.state = computeSuiteRunningState(suite);
|
|
2954
|
+
this.testReporter.updateSuites();
|
|
2955
|
+
}
|
|
2956
|
+
/**
|
|
2957
|
+
* Finalize suite with test results
|
|
2958
|
+
*/
|
|
2959
|
+
finalizeSuite(suite) {
|
|
2960
|
+
if (suite.end) {
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
stampSuiteEnd(suite);
|
|
2964
|
+
suite.state = computeSuiteFinalStateStrict(suite);
|
|
2965
|
+
this.testReporter.onSuiteEnd(suite);
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Get all suites
|
|
2969
|
+
*/
|
|
2970
|
+
getAllSuites() {
|
|
2971
|
+
return this.currentSuiteByFile;
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
|
|
2975
|
+
// src/helpers/browserProxy.ts
|
|
2976
|
+
import logger8 from "@wdio/logger";
|
|
2977
|
+
|
|
2978
|
+
// src/helpers/serializeCommandResult.ts
|
|
2979
|
+
function serializeCommandResult(callbackResult, methodName) {
|
|
2980
|
+
if (callbackResult === null || callbackResult === void 0) {
|
|
2981
|
+
return void 0;
|
|
2982
|
+
}
|
|
2983
|
+
const isBooleanCommand = BOOLEAN_COMMAND_PATTERN.test(methodName);
|
|
2984
|
+
if (typeof callbackResult === "object") {
|
|
2985
|
+
const r = callbackResult;
|
|
2986
|
+
if ("passed" in r) {
|
|
2987
|
+
return r.passed ? true : {
|
|
2988
|
+
passed: false,
|
|
2989
|
+
actual: r.actual,
|
|
2990
|
+
expected: r.expected,
|
|
2991
|
+
message: r.message
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
if ("value" in r) {
|
|
2995
|
+
const raw = r.value;
|
|
2996
|
+
return raw === null && isBooleanCommand ? false : raw;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
if (typeof callbackResult !== "function") {
|
|
3000
|
+
try {
|
|
3001
|
+
return JSON.parse(JSON.stringify(callbackResult));
|
|
3002
|
+
} catch {
|
|
3003
|
+
return String(callbackResult);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return void 0;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// src/helpers/browserProxy.ts
|
|
3010
|
+
var log8 = logger8("@wdio/nightwatch-devtools:browserProxy");
|
|
3011
|
+
var BrowserProxy = class {
|
|
3012
|
+
constructor(sessionCapturer, testManager, getCurrentTest) {
|
|
3013
|
+
this.sessionCapturer = sessionCapturer;
|
|
3014
|
+
this.testManager = testManager;
|
|
3015
|
+
this.getCurrentTest = getCurrentTest;
|
|
3016
|
+
}
|
|
3017
|
+
sessionCapturer;
|
|
3018
|
+
testManager;
|
|
3019
|
+
getCurrentTest;
|
|
3020
|
+
/** Tracks which browser *instances* have already been proxied to avoid double-wrapping. */
|
|
3021
|
+
proxiedBrowsers = /* @__PURE__ */ new WeakSet();
|
|
3022
|
+
commandStack = [];
|
|
3023
|
+
lastCommandSig = null;
|
|
3024
|
+
currentTestFullPath = null;
|
|
3025
|
+
/**
|
|
3026
|
+
* Tracks the last captured command so that consecutive retries of the same
|
|
3027
|
+
* command (e.g. getText inside a waitFor loop) overwrite the previous entry
|
|
3028
|
+
* rather than appending, showing only the final execution result.
|
|
3029
|
+
*/
|
|
3030
|
+
retryTracker = new RetryTracker();
|
|
3031
|
+
/**
|
|
3032
|
+
* Update the session capturer reference after a WebDriver session change.
|
|
3033
|
+
* Does NOT re-wrap browser methods — wrapping is permanent per browser object.
|
|
3034
|
+
*/
|
|
3035
|
+
updateSessionCapturer(capturer) {
|
|
3036
|
+
this.sessionCapturer = capturer;
|
|
3037
|
+
}
|
|
3038
|
+
resetCommandTracking() {
|
|
3039
|
+
this.commandStack = [];
|
|
3040
|
+
this.lastCommandSig = null;
|
|
3041
|
+
this.retryTracker.reset();
|
|
3042
|
+
}
|
|
3043
|
+
getCurrentTestFullPath() {
|
|
3044
|
+
return this.currentTestFullPath;
|
|
3045
|
+
}
|
|
3046
|
+
setCurrentTestFullPath(path9) {
|
|
3047
|
+
this.currentTestFullPath = path9;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Wrap browser navigation methods (url / navigate / navigateTo) to inject
|
|
3051
|
+
* the DevTools script after every navigation.
|
|
3052
|
+
*
|
|
3053
|
+
* Uses `browser` from the closure (not `this` inside perform) so it works
|
|
3054
|
+
* for both standard Nightwatch (chainable API) and Cucumber async/await mode
|
|
3055
|
+
* where `this` inside a perform callback is not the browser.
|
|
3056
|
+
*/
|
|
3057
|
+
wrapUrlMethod(browser) {
|
|
3058
|
+
const sessionCapturer = this.sessionCapturer;
|
|
3059
|
+
const b = browser;
|
|
3060
|
+
const wrapNav = (methodName) => {
|
|
3061
|
+
if (typeof b[methodName] !== "function") {
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
const original = b[methodName].bind(browser);
|
|
3065
|
+
b[methodName] = function(...args) {
|
|
3066
|
+
const result = original(...args);
|
|
3067
|
+
const injectAndCapture = () => {
|
|
3068
|
+
log8.info(`[nav] ${methodName}(${args[0] ?? ""}) \u2014 injecting script`);
|
|
3069
|
+
return sessionCapturer.injectScript(browser).then(() => sessionCapturer.captureTrace(browser)).catch(
|
|
3070
|
+
(err) => log8.error(`Failed to inject script: ${err.message}`)
|
|
3071
|
+
);
|
|
3072
|
+
};
|
|
3073
|
+
const chainable = result;
|
|
3074
|
+
if (chainable && typeof chainable.perform === "function") {
|
|
3075
|
+
chainable.perform((done) => {
|
|
3076
|
+
injectAndCapture().finally(() => done && done());
|
|
3077
|
+
});
|
|
3078
|
+
return result;
|
|
734
3079
|
}
|
|
3080
|
+
return Promise.resolve(result).then(injectAndCapture).catch(() => {
|
|
3081
|
+
});
|
|
3082
|
+
};
|
|
3083
|
+
};
|
|
3084
|
+
wrapNav("url");
|
|
3085
|
+
wrapNav("navigate");
|
|
3086
|
+
wrapNav("navigateTo");
|
|
3087
|
+
log8.info("\u2713 Script injection wrapped");
|
|
3088
|
+
}
|
|
3089
|
+
wrapBrowserCommands(browser) {
|
|
3090
|
+
if (this.proxiedBrowsers.has(browser)) {
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
const browserAny = browser;
|
|
3094
|
+
const allMethods = /* @__PURE__ */ new Set([
|
|
3095
|
+
...Object.keys(browser),
|
|
3096
|
+
...Object.getOwnPropertyNames(Object.getPrototypeOf(browser))
|
|
3097
|
+
]);
|
|
3098
|
+
const wrappedMethods = [];
|
|
3099
|
+
allMethods.forEach((methodName) => {
|
|
3100
|
+
if (methodName === "constructor" || typeof browserAny[methodName] !== "function") {
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
if (INTERNAL_COMMANDS_TO_IGNORE.includes(
|
|
3104
|
+
methodName
|
|
3105
|
+
) || methodName.startsWith("__")) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const originalMethod = browserAny[methodName].bind(browser);
|
|
3109
|
+
browserAny[methodName] = (...args) => {
|
|
3110
|
+
return this.handleCommandExecution(
|
|
3111
|
+
browser,
|
|
3112
|
+
browserAny,
|
|
3113
|
+
methodName,
|
|
3114
|
+
originalMethod,
|
|
3115
|
+
args
|
|
3116
|
+
);
|
|
3117
|
+
};
|
|
3118
|
+
wrappedMethods.push(methodName);
|
|
3119
|
+
});
|
|
3120
|
+
this.proxiedBrowsers.add(browser);
|
|
3121
|
+
log8.info(`\u2713 Wrapped ${wrappedMethods.length} browser methods`);
|
|
3122
|
+
}
|
|
3123
|
+
handleRetryReplacement(browser, methodName, logArgs, serializedResult, effectiveUid, callSource, commandTimestamp) {
|
|
3124
|
+
const { entry, oldTimestamp } = this.sessionCapturer.replaceCommand(
|
|
3125
|
+
this.retryTracker.lastId,
|
|
3126
|
+
methodName,
|
|
3127
|
+
logArgs,
|
|
3128
|
+
serializedResult,
|
|
3129
|
+
void 0,
|
|
3130
|
+
effectiveUid,
|
|
3131
|
+
callSource,
|
|
3132
|
+
commandTimestamp
|
|
3133
|
+
);
|
|
3134
|
+
this.retryTracker.setLastId(entry._id ?? null);
|
|
3135
|
+
this.sessionCapturer.sendReplaceCommand(oldTimestamp, entry);
|
|
3136
|
+
this.attachScreenshot(browser, entry, methodName, " (retry)");
|
|
3137
|
+
}
|
|
3138
|
+
captureFreshCommand(browser, methodName, logArgs, serializedResult, effectiveUid, callSource, commandTimestamp, cmdSig) {
|
|
3139
|
+
this.retryTracker.setLastSig(cmdSig);
|
|
3140
|
+
this.retryTracker.setLastId(null);
|
|
3141
|
+
this.sessionCapturer.captureCommand(
|
|
3142
|
+
methodName,
|
|
3143
|
+
logArgs,
|
|
3144
|
+
serializedResult,
|
|
3145
|
+
void 0,
|
|
3146
|
+
effectiveUid,
|
|
3147
|
+
callSource,
|
|
3148
|
+
commandTimestamp
|
|
3149
|
+
).catch(
|
|
3150
|
+
(err) => log8.error(`Failed to capture ${methodName}: ${err.message}`)
|
|
3151
|
+
);
|
|
3152
|
+
const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1];
|
|
3153
|
+
if (lastCommand) {
|
|
3154
|
+
this.retryTracker.setLastId(lastCommand._id ?? null);
|
|
3155
|
+
this.sessionCapturer.sendCommand(lastCommand);
|
|
3156
|
+
log8.info(`[command] ${methodName}`);
|
|
3157
|
+
this.attachScreenshot(browser, lastCommand, methodName);
|
|
3158
|
+
}
|
|
3159
|
+
this.maybeRepollMutations(browser, methodName);
|
|
3160
|
+
}
|
|
3161
|
+
// After DOM-mutating commands, re-poll mutations from the injected script
|
|
3162
|
+
// so the browser preview stays in sync. setTimeout runs OUTSIDE Nightwatch's
|
|
3163
|
+
// current callback stack (safer queue-wise).
|
|
3164
|
+
maybeRepollMutations(browser, methodName) {
|
|
3165
|
+
const isDomMutating = NAVIGATION_COMMANDS.includes(methodName) || [
|
|
3166
|
+
"click",
|
|
3167
|
+
"doubleClick",
|
|
3168
|
+
"rightClick",
|
|
3169
|
+
"setValue",
|
|
3170
|
+
"clearValue",
|
|
3171
|
+
"sendKeys",
|
|
3172
|
+
"submitForm",
|
|
3173
|
+
"back",
|
|
3174
|
+
"forward",
|
|
3175
|
+
"refresh"
|
|
3176
|
+
].includes(methodName);
|
|
3177
|
+
if (!isDomMutating) {
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
setTimeout(() => {
|
|
3181
|
+
this.sessionCapturer.captureTrace(browser).catch(() => {
|
|
3182
|
+
});
|
|
3183
|
+
}, 200);
|
|
3184
|
+
}
|
|
3185
|
+
popCommandStackIfMatches(methodName, cmdSig) {
|
|
3186
|
+
const stackFrame = this.commandStack[this.commandStack.length - 1];
|
|
3187
|
+
if (stackFrame?.command === methodName && stackFrame.signature === cmdSig) {
|
|
3188
|
+
this.commandStack.pop();
|
|
735
3189
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
3190
|
+
}
|
|
3191
|
+
// Result-capturing callback factory — called by Nightwatch's async queue
|
|
3192
|
+
// when the command completes. This is where we get the *actual* result.
|
|
3193
|
+
makeCaptureCallback(browser, methodName, logArgs, cmdSig, callSource, commandTimestamp, testUid, userCallback) {
|
|
3194
|
+
return (callbackResult) => {
|
|
3195
|
+
this.popCommandStackIfMatches(methodName, cmdSig);
|
|
3196
|
+
const serializedResult = serializeCommandResult(
|
|
3197
|
+
callbackResult,
|
|
3198
|
+
methodName
|
|
3199
|
+
);
|
|
3200
|
+
const effectiveUid = this.getCurrentTest()?.uid ?? testUid;
|
|
3201
|
+
if (effectiveUid) {
|
|
3202
|
+
if (this.retryTracker.isRetry(cmdSig)) {
|
|
3203
|
+
this.handleRetryReplacement(
|
|
3204
|
+
browser,
|
|
3205
|
+
methodName,
|
|
3206
|
+
logArgs,
|
|
3207
|
+
serializedResult,
|
|
3208
|
+
effectiveUid,
|
|
3209
|
+
callSource,
|
|
3210
|
+
commandTimestamp
|
|
3211
|
+
);
|
|
3212
|
+
} else {
|
|
3213
|
+
this.captureFreshCommand(
|
|
3214
|
+
browser,
|
|
3215
|
+
methodName,
|
|
3216
|
+
logArgs,
|
|
3217
|
+
serializedResult,
|
|
3218
|
+
effectiveUid,
|
|
3219
|
+
callSource,
|
|
3220
|
+
commandTimestamp,
|
|
3221
|
+
cmdSig
|
|
3222
|
+
);
|
|
747
3223
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
testEnv,
|
|
758
|
-
host,
|
|
759
|
-
modulePath,
|
|
760
|
-
options: this.#buildMetadataOptions()
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
catch (err) {
|
|
765
|
-
log.error(`Error in event handler: ${err.message}`);
|
|
766
|
-
}
|
|
767
|
-
};
|
|
768
|
-
eventHub.on('TestSuiteStarted', handleSessionMetadata);
|
|
769
|
-
eventHub.on('TestRunStarted', handleSessionMetadata);
|
|
3224
|
+
}
|
|
3225
|
+
if (userCallback) {
|
|
3226
|
+
return userCallback(callbackResult);
|
|
3227
|
+
}
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
pushCommandStackIfNew(methodName, cmdSig, callSource) {
|
|
3231
|
+
if (this.lastCommandSig === cmdSig) {
|
|
3232
|
+
return;
|
|
770
3233
|
}
|
|
3234
|
+
this.commandStack.push({
|
|
3235
|
+
command: methodName,
|
|
3236
|
+
callSource,
|
|
3237
|
+
signature: cmdSig
|
|
3238
|
+
});
|
|
3239
|
+
this.lastCommandSig = cmdSig;
|
|
3240
|
+
}
|
|
3241
|
+
handleCommandExecution(browser, browserAny, methodName, originalMethod, args) {
|
|
3242
|
+
this.testManager.startTestIfPending(
|
|
3243
|
+
this.testManager.detectTestBoundary(
|
|
3244
|
+
browserAny.currentTest
|
|
3245
|
+
)
|
|
3246
|
+
);
|
|
3247
|
+
const callInfo = getCallSourceFromStack();
|
|
3248
|
+
if (callInfo.filePath && !this.currentTestFullPath) {
|
|
3249
|
+
this.currentTestFullPath = callInfo.filePath;
|
|
3250
|
+
}
|
|
3251
|
+
const lastArg = args[args.length - 1];
|
|
3252
|
+
const hasUserCallback = typeof lastArg === "function";
|
|
3253
|
+
const userCallback = hasUserCallback ? lastArg : null;
|
|
3254
|
+
const logArgs = hasUserCallback ? args.slice(0, -1) : args;
|
|
3255
|
+
const cmdSig = JSON.stringify({
|
|
3256
|
+
command: methodName,
|
|
3257
|
+
args: logArgs,
|
|
3258
|
+
src: callInfo.callSource
|
|
3259
|
+
});
|
|
3260
|
+
this.pushCommandStackIfNew(methodName, cmdSig, callInfo.callSource);
|
|
3261
|
+
const callSource = callInfo.callSource;
|
|
3262
|
+
const commandTimestamp = Date.now();
|
|
3263
|
+
const captureCallback = this.makeCaptureCallback(
|
|
3264
|
+
browser,
|
|
3265
|
+
methodName,
|
|
3266
|
+
logArgs,
|
|
3267
|
+
cmdSig,
|
|
3268
|
+
callSource,
|
|
3269
|
+
commandTimestamp,
|
|
3270
|
+
this.getCurrentTest()?.uid,
|
|
3271
|
+
userCallback
|
|
3272
|
+
);
|
|
3273
|
+
const modifiedArgs = [...logArgs, captureCallback];
|
|
3274
|
+
try {
|
|
3275
|
+
return originalMethod(...modifiedArgs);
|
|
3276
|
+
} catch (error) {
|
|
3277
|
+
this.popCommandStackIfMatches(methodName, cmdSig);
|
|
3278
|
+
this.captureCommandError(methodName, logArgs, error, callSource);
|
|
3279
|
+
throw error;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
captureCommandError(methodName, args, error, callSource) {
|
|
3283
|
+
const currentTest = this.getCurrentTest();
|
|
3284
|
+
if (!currentTest) {
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
const normalizedError = toError(error);
|
|
3288
|
+
log8.error(`[command error] ${methodName}: ${normalizedError.message}`);
|
|
3289
|
+
this.sessionCapturer.captureCommand(
|
|
3290
|
+
methodName,
|
|
3291
|
+
args,
|
|
3292
|
+
void 0,
|
|
3293
|
+
normalizedError,
|
|
3294
|
+
currentTest.uid,
|
|
3295
|
+
callSource
|
|
3296
|
+
).catch(
|
|
3297
|
+
(err) => log8.error(`Failed to capture ${methodName}: ${err.message}`)
|
|
3298
|
+
);
|
|
3299
|
+
const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1];
|
|
3300
|
+
if (lastCommand) {
|
|
3301
|
+
this.sessionCapturer.sendCommand(lastCommand);
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
isProxied(browser) {
|
|
3305
|
+
return this.proxiedBrowsers.has(browser);
|
|
3306
|
+
}
|
|
3307
|
+
/**
|
|
3308
|
+
* Fire-and-forget: pull a screenshot via the WebDriver HTTP endpoint and
|
|
3309
|
+
* attach it to an already-captured command entry. The `suffix` is appended
|
|
3310
|
+
* to the log line so retried-command screenshots show `(retry)`. Errors
|
|
3311
|
+
* are silently swallowed — screenshots are best-effort and shouldn't fail
|
|
3312
|
+
* the run.
|
|
3313
|
+
*/
|
|
3314
|
+
attachScreenshot(browser, entry, methodName, suffix = "") {
|
|
3315
|
+
const ts = entry.timestamp ?? 0;
|
|
3316
|
+
this.sessionCapturer.takeScreenshotViaHttp(browser).then((screenshot) => {
|
|
3317
|
+
if (screenshot) {
|
|
3318
|
+
entry.screenshot = screenshot;
|
|
3319
|
+
this.sessionCapturer.sendReplaceCommand(ts, entry);
|
|
3320
|
+
log8.info(`[screenshot] Attached to ${methodName}${suffix}`);
|
|
3321
|
+
}
|
|
3322
|
+
}).catch(() => {
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
};
|
|
3326
|
+
|
|
3327
|
+
// src/screencast.ts
|
|
3328
|
+
import logger9 from "@wdio/logger";
|
|
3329
|
+
var log9 = logger9("@wdio/nightwatch-devtools:ScreencastRecorder");
|
|
3330
|
+
var ScreencastRecorder = class extends ScreencastRecorderBase {
|
|
3331
|
+
#sessionCapturer;
|
|
3332
|
+
constructor(sessionCapturer, options) {
|
|
3333
|
+
super(options);
|
|
3334
|
+
this.#sessionCapturer = sessionCapturer;
|
|
3335
|
+
}
|
|
3336
|
+
onPollingStarted(intervalMs) {
|
|
3337
|
+
log9.info(
|
|
3338
|
+
`\u2713 Screencast recording started (polling mode, ${intervalMs} ms interval)`
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
onPollingStopped(frameCount) {
|
|
3342
|
+
log9.info(`\u2713 Screencast stopped \u2014 ${frameCount} frame(s) collected`);
|
|
3343
|
+
}
|
|
3344
|
+
onUnavailable(err) {
|
|
3345
|
+
log9.warn(
|
|
3346
|
+
`Screencast unavailable (${errorMessage(err)}). Recording skipped.`
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
async takeScreenshot() {
|
|
3350
|
+
const browser = this.driver;
|
|
3351
|
+
if (!browser) {
|
|
3352
|
+
return null;
|
|
3353
|
+
}
|
|
3354
|
+
return this.#sessionCapturer.takeScreenshotViaHttp(browser);
|
|
3355
|
+
}
|
|
3356
|
+
};
|
|
3357
|
+
|
|
3358
|
+
// src/session-init.ts
|
|
3359
|
+
var log10 = logger10("@wdio/nightwatch-devtools:session-init");
|
|
3360
|
+
async function handleSessionChange(ctx, finalizeCurrent) {
|
|
3361
|
+
log10.info("Browser session changed \u2014 reconnecting WebSocket only");
|
|
3362
|
+
ctx.isScriptInjected = false;
|
|
3363
|
+
ctx.bidiAttachAttempted = false;
|
|
3364
|
+
await finalizeCurrent();
|
|
3365
|
+
ctx.sessionCapturer?.cleanup();
|
|
3366
|
+
ctx.sessionCapturer = null;
|
|
771
3367
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
3368
|
+
function initReporterChain(ctx) {
|
|
3369
|
+
ctx.testReporter = new TestReporter((suitesData) => {
|
|
3370
|
+
if (ctx.sessionCapturer) {
|
|
3371
|
+
ctx.sessionCapturer.sendUpstream("suites", suitesData);
|
|
3372
|
+
}
|
|
3373
|
+
});
|
|
3374
|
+
ctx.testManager = new TestManager(ctx.testReporter);
|
|
3375
|
+
ctx.suiteManager = new SuiteManager(ctx.testReporter);
|
|
3376
|
+
ctx.browserProxy = new BrowserProxy(
|
|
3377
|
+
ctx.sessionCapturer,
|
|
3378
|
+
ctx.testManager,
|
|
3379
|
+
() => ctx.getCurrentTest() ?? ctx.getCurrentScenarioSuite()
|
|
3380
|
+
);
|
|
3381
|
+
}
|
|
3382
|
+
function rebindReporterToNewSession(ctx) {
|
|
3383
|
+
ctx.testReporter.updateUpstream((suitesData) => {
|
|
3384
|
+
if (ctx.sessionCapturer) {
|
|
3385
|
+
ctx.sessionCapturer.sendUpstream("suites", suitesData);
|
|
3386
|
+
}
|
|
3387
|
+
});
|
|
3388
|
+
ctx.browserProxy.updateSessionCapturer(ctx.sessionCapturer);
|
|
3389
|
+
ctx.testReporter.updateSuites();
|
|
3390
|
+
}
|
|
3391
|
+
function broadcastSessionMetadata(ctx, browser) {
|
|
3392
|
+
const capabilities = browser.capabilities || {};
|
|
3393
|
+
const desiredCapabilities = browser.desiredCapabilities || {};
|
|
3394
|
+
const sessionId = browser.sessionId;
|
|
3395
|
+
const opts = browser.options || {};
|
|
3396
|
+
if (ctx.srcFolders.length === 0) {
|
|
3397
|
+
const sf = opts.src_folders;
|
|
3398
|
+
ctx.srcFolders = Array.isArray(sf) ? sf : sf ? [sf] : [];
|
|
3399
|
+
}
|
|
3400
|
+
ctx.sessionCapturer.sendUpstream("metadata", {
|
|
3401
|
+
type: "testrunner" /* Testrunner */,
|
|
3402
|
+
capabilities,
|
|
3403
|
+
desiredCapabilities,
|
|
3404
|
+
sessionId,
|
|
3405
|
+
testEnv: opts.testEnv,
|
|
3406
|
+
host: opts.webdriver?.host,
|
|
3407
|
+
options: ctx.buildMetadataOptions(),
|
|
3408
|
+
url: ""
|
|
3409
|
+
});
|
|
3410
|
+
const browserName = capabilities.browserName || desiredCapabilities.browserName || "unknown";
|
|
3411
|
+
const browserVersion = capabilities.browserVersion || capabilities.version || "";
|
|
3412
|
+
log10.info(
|
|
3413
|
+
`\u2713 Browser: ${browserName}${browserVersion ? " " + browserVersion : ""} (session: ${sessionId})`
|
|
3414
|
+
);
|
|
3415
|
+
const loggingPrefs = capabilities["goog:loggingPrefs"] || desiredCapabilities["goog:loggingPrefs"] || {};
|
|
3416
|
+
if (!loggingPrefs.performance && !ctx.bidiEnabled) {
|
|
3417
|
+
log10.warn(
|
|
3418
|
+
"\u26A0 Network tab will be empty \u2014 add 'goog:loggingPrefs': { performance: 'ALL' } to your capabilities (or enable bidi:true)"
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
async function tryAttachBidi(ctx, browser) {
|
|
3423
|
+
if (!ctx.bidiEnabled || ctx.bidiAttachAttempted) {
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
ctx.bidiAttachAttempted = true;
|
|
3427
|
+
const driver = browser.driver;
|
|
3428
|
+
if (!driver) {
|
|
3429
|
+
log10.warn("bidi:true set but browser.driver unavailable \u2014 skipping");
|
|
3430
|
+
return;
|
|
3431
|
+
}
|
|
3432
|
+
const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-OH4ZFDN5.js");
|
|
3433
|
+
const ok = await attachBidiHandlers(
|
|
3434
|
+
driver,
|
|
3435
|
+
buildBidiSinks(ctx.sessionCapturer)
|
|
3436
|
+
);
|
|
3437
|
+
if (ok) {
|
|
3438
|
+
ctx.sessionCapturer.bidiActive = true;
|
|
3439
|
+
log10.info("\u2713 BiDi attached \u2014 perf-log network capture disabled");
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
async function tryStartScreencast(ctx, browser, sessionId) {
|
|
3443
|
+
if (!ctx.screencastOptions.enabled || ctx.screencastRecorder || !sessionId) {
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
ctx.screencastRecorder = new ScreencastRecorder(
|
|
3447
|
+
ctx.sessionCapturer,
|
|
3448
|
+
ctx.screencastOptions
|
|
3449
|
+
);
|
|
3450
|
+
ctx.screencastSessionId = sessionId;
|
|
3451
|
+
log10.info(`\u{1F3AC} Starting screencast for session ${sessionId}`);
|
|
3452
|
+
await ctx.screencastRecorder.start(browser);
|
|
3453
|
+
}
|
|
3454
|
+
async function ensureSessionInitialized(ctx, browser, finalizeCurrentScreencast2) {
|
|
3455
|
+
const currentSessionId = browser.sessionId;
|
|
3456
|
+
const isSessionChange = currentSessionId && ctx.lastSessionId && currentSessionId !== ctx.lastSessionId;
|
|
3457
|
+
if (isSessionChange) {
|
|
3458
|
+
await handleSessionChange(ctx, finalizeCurrentScreencast2);
|
|
3459
|
+
}
|
|
3460
|
+
ctx.lastSessionId = currentSessionId ?? null;
|
|
3461
|
+
if (ctx.sessionCapturer) {
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
await new Promise(
|
|
3465
|
+
(resolve2) => setTimeout(resolve2, TIMING.INITIAL_CONNECTION_WAIT)
|
|
3466
|
+
);
|
|
3467
|
+
ctx.sessionCapturer = new SessionCapturer(
|
|
3468
|
+
{ port: ctx.port, hostname: ctx.hostname },
|
|
3469
|
+
browser
|
|
3470
|
+
);
|
|
3471
|
+
const connected = await ctx.sessionCapturer.waitForConnection(3e3);
|
|
3472
|
+
if (!connected) {
|
|
3473
|
+
log10.error("\u274C Worker WebSocket failed to connect!");
|
|
3474
|
+
}
|
|
3475
|
+
if (!ctx.testReporter) {
|
|
3476
|
+
initReporterChain(ctx);
|
|
3477
|
+
} else {
|
|
3478
|
+
rebindReporterToNewSession(ctx);
|
|
3479
|
+
}
|
|
3480
|
+
broadcastSessionMetadata(ctx, browser);
|
|
3481
|
+
await tryAttachBidi(ctx, browser);
|
|
3482
|
+
await tryStartScreencast(ctx, browser, browser.sessionId);
|
|
3483
|
+
}
|
|
3484
|
+
async function finalizeCurrentScreencast(ctx) {
|
|
3485
|
+
if (!ctx.screencastRecorder || !ctx.screencastSessionId) {
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
await finalizeScreencast({
|
|
3489
|
+
recorder: ctx.screencastRecorder,
|
|
3490
|
+
sessionId: ctx.screencastSessionId,
|
|
3491
|
+
filenamePrefix: "nightwatch-video",
|
|
3492
|
+
outputDir: resolveAdapterOutputDir({
|
|
3493
|
+
testFilePath: ctx.browserProxy?.getCurrentTestFullPath?.() ?? void 0,
|
|
3494
|
+
configPath: ctx.configPath
|
|
3495
|
+
}),
|
|
3496
|
+
captureFormat: ctx.screencastOptions.captureFormat,
|
|
3497
|
+
sendUpstream: (scope, data) => ctx.sessionCapturer?.sendUpstream(scope, data),
|
|
3498
|
+
onLog: (level, message) => log10[level](message)
|
|
3499
|
+
});
|
|
3500
|
+
ctx.screencastRecorder = void 0;
|
|
3501
|
+
ctx.screencastSessionId = void 0;
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
// src/index.ts
|
|
3505
|
+
var log11 = logger11("@wdio/nightwatch-devtools");
|
|
3506
|
+
var NightwatchDevToolsPlugin = class {
|
|
3507
|
+
options;
|
|
3508
|
+
sessionCapturer;
|
|
3509
|
+
testReporter;
|
|
3510
|
+
testManager;
|
|
3511
|
+
suiteManager;
|
|
3512
|
+
browserProxy;
|
|
3513
|
+
isScriptInjected = false;
|
|
3514
|
+
#currentTest = null;
|
|
3515
|
+
#currentScenarioSuite = null;
|
|
3516
|
+
#currentStep = null;
|
|
3517
|
+
#lastSessionId = null;
|
|
3518
|
+
#devtoolsBrowser;
|
|
3519
|
+
#userDataDir;
|
|
3520
|
+
#isCucumberRunner = false;
|
|
3521
|
+
#passCount = 0;
|
|
3522
|
+
#failCount = 0;
|
|
3523
|
+
#skipCount = 0;
|
|
3524
|
+
#configPath;
|
|
3525
|
+
#srcFolders = [];
|
|
3526
|
+
#getRerunLabel() {
|
|
3527
|
+
return process.env[REUSE_ENV.RERUN_ENTRY_TYPE] === "test" ? process.env[REUSE_ENV.RERUN_LABEL]?.trim() : void 0;
|
|
3528
|
+
}
|
|
3529
|
+
#screencastOptions;
|
|
3530
|
+
#screencastRecorder;
|
|
3531
|
+
#screencastSessionId;
|
|
3532
|
+
#bidiEnabled = false;
|
|
3533
|
+
#bidiAttachAttempted = false;
|
|
3534
|
+
constructor(options = {}) {
|
|
3535
|
+
this.options = {
|
|
3536
|
+
port: options.port ?? 3e3,
|
|
3537
|
+
hostname: options.hostname ?? "localhost",
|
|
3538
|
+
screencast: options.screencast ?? {},
|
|
3539
|
+
bidi: options.bidi ?? false
|
|
3540
|
+
};
|
|
3541
|
+
this.#screencastOptions = {
|
|
3542
|
+
...SCREENCAST_DEFAULTS,
|
|
3543
|
+
...options.screencast ?? {}
|
|
3544
|
+
};
|
|
3545
|
+
this.#bidiEnabled = options.bidi === true;
|
|
3546
|
+
}
|
|
3547
|
+
// Single internals "bag" — structurally satisfies all 4 lifecycle ctx
|
|
3548
|
+
// interfaces. Lifecycle modules cast it to their narrow type at call time.
|
|
3549
|
+
#internals;
|
|
3550
|
+
// Declarative accessor map — splitting this purely to satisfy the
|
|
3551
|
+
// line-count rule hurts readability; the body is mechanical wiring.
|
|
3552
|
+
// eslint-disable-next-line max-lines-per-function
|
|
3553
|
+
#getInternals() {
|
|
3554
|
+
if (this.#internals) {
|
|
3555
|
+
return this.#internals;
|
|
3556
|
+
}
|
|
3557
|
+
const self = this;
|
|
3558
|
+
this.#internals = {
|
|
3559
|
+
get options() {
|
|
3560
|
+
return self.options;
|
|
3561
|
+
},
|
|
3562
|
+
get hostname() {
|
|
3563
|
+
return self.options.hostname;
|
|
3564
|
+
},
|
|
3565
|
+
get port() {
|
|
3566
|
+
return self.options.port;
|
|
3567
|
+
},
|
|
3568
|
+
get screencastOptions() {
|
|
3569
|
+
return self.#screencastOptions;
|
|
3570
|
+
},
|
|
3571
|
+
get bidiEnabled() {
|
|
3572
|
+
return self.#bidiEnabled;
|
|
3573
|
+
},
|
|
3574
|
+
get sessionCapturer() {
|
|
3575
|
+
return self.sessionCapturer;
|
|
3576
|
+
},
|
|
3577
|
+
set sessionCapturer(v) {
|
|
3578
|
+
self.sessionCapturer = v;
|
|
3579
|
+
},
|
|
3580
|
+
get testReporter() {
|
|
3581
|
+
return self.testReporter;
|
|
3582
|
+
},
|
|
3583
|
+
set testReporter(v) {
|
|
3584
|
+
self.testReporter = v;
|
|
3585
|
+
},
|
|
3586
|
+
get testManager() {
|
|
3587
|
+
return self.testManager;
|
|
3588
|
+
},
|
|
3589
|
+
set testManager(v) {
|
|
3590
|
+
self.testManager = v;
|
|
3591
|
+
},
|
|
3592
|
+
get suiteManager() {
|
|
3593
|
+
return self.suiteManager;
|
|
3594
|
+
},
|
|
3595
|
+
set suiteManager(v) {
|
|
3596
|
+
self.suiteManager = v;
|
|
3597
|
+
},
|
|
3598
|
+
get browserProxy() {
|
|
3599
|
+
return self.browserProxy;
|
|
3600
|
+
},
|
|
3601
|
+
set browserProxy(v) {
|
|
3602
|
+
self.browserProxy = v;
|
|
3603
|
+
},
|
|
3604
|
+
get isScriptInjected() {
|
|
3605
|
+
return self.isScriptInjected;
|
|
3606
|
+
},
|
|
3607
|
+
set isScriptInjected(v) {
|
|
3608
|
+
self.isScriptInjected = v;
|
|
3609
|
+
},
|
|
3610
|
+
get devtoolsBrowser() {
|
|
3611
|
+
return self.#devtoolsBrowser;
|
|
3612
|
+
},
|
|
3613
|
+
set devtoolsBrowser(v) {
|
|
3614
|
+
self.#devtoolsBrowser = v;
|
|
3615
|
+
},
|
|
3616
|
+
get userDataDir() {
|
|
3617
|
+
return self.#userDataDir;
|
|
3618
|
+
},
|
|
3619
|
+
set userDataDir(v) {
|
|
3620
|
+
self.#userDataDir = v;
|
|
3621
|
+
},
|
|
3622
|
+
get passCount() {
|
|
3623
|
+
return self.#passCount;
|
|
3624
|
+
},
|
|
3625
|
+
set passCount(v) {
|
|
3626
|
+
self.#passCount = v;
|
|
3627
|
+
},
|
|
3628
|
+
get failCount() {
|
|
3629
|
+
return self.#failCount;
|
|
3630
|
+
},
|
|
3631
|
+
set failCount(v) {
|
|
3632
|
+
self.#failCount = v;
|
|
3633
|
+
},
|
|
3634
|
+
get skipCount() {
|
|
3635
|
+
return self.#skipCount;
|
|
3636
|
+
},
|
|
3637
|
+
set skipCount(v) {
|
|
3638
|
+
self.#skipCount = v;
|
|
3639
|
+
},
|
|
3640
|
+
get lastSessionId() {
|
|
3641
|
+
return self.#lastSessionId;
|
|
3642
|
+
},
|
|
3643
|
+
set lastSessionId(v) {
|
|
3644
|
+
self.#lastSessionId = v;
|
|
3645
|
+
},
|
|
3646
|
+
get bidiAttachAttempted() {
|
|
3647
|
+
return self.#bidiAttachAttempted;
|
|
3648
|
+
},
|
|
3649
|
+
set bidiAttachAttempted(v) {
|
|
3650
|
+
self.#bidiAttachAttempted = v;
|
|
3651
|
+
},
|
|
3652
|
+
get srcFolders() {
|
|
3653
|
+
return self.#srcFolders;
|
|
3654
|
+
},
|
|
3655
|
+
set srcFolders(v) {
|
|
3656
|
+
self.#srcFolders = v;
|
|
3657
|
+
},
|
|
3658
|
+
get screencastRecorder() {
|
|
3659
|
+
return self.#screencastRecorder;
|
|
3660
|
+
},
|
|
3661
|
+
set screencastRecorder(v) {
|
|
3662
|
+
self.#screencastRecorder = v;
|
|
3663
|
+
},
|
|
3664
|
+
get screencastSessionId() {
|
|
3665
|
+
return self.#screencastSessionId;
|
|
3666
|
+
},
|
|
3667
|
+
set screencastSessionId(v) {
|
|
3668
|
+
self.#screencastSessionId = v;
|
|
3669
|
+
},
|
|
3670
|
+
get configPath() {
|
|
3671
|
+
return self.#configPath;
|
|
3672
|
+
},
|
|
3673
|
+
getCurrentTest: () => self.#currentTest,
|
|
3674
|
+
getCurrentScenarioSuite: () => self.#currentScenarioSuite,
|
|
3675
|
+
getCurrentStep: () => self.#currentStep,
|
|
3676
|
+
setCurrentTest: (t) => {
|
|
3677
|
+
self.#currentTest = t;
|
|
3678
|
+
},
|
|
3679
|
+
setCurrentScenarioSuite: (s) => {
|
|
3680
|
+
self.#currentScenarioSuite = s;
|
|
3681
|
+
},
|
|
3682
|
+
setCurrentStep: (s) => {
|
|
3683
|
+
self.#currentStep = s;
|
|
3684
|
+
},
|
|
3685
|
+
clearExecutionData: () => {
|
|
3686
|
+
self.testReporter.clearExecutionData();
|
|
3687
|
+
self.suiteManager.clearExecutionData();
|
|
3688
|
+
},
|
|
3689
|
+
buildMetadataOptions: () => self.#buildMetadataOptions(),
|
|
3690
|
+
ensureSessionInitialized: (b) => self.#ensureSessionInitialized(b),
|
|
3691
|
+
wrapBrowserOnce: (b) => self.#wrapBrowserOnce(b),
|
|
3692
|
+
incrementCount: (s) => self.#incrementCount(s),
|
|
3693
|
+
testIcon: (s) => self.#testIcon(s),
|
|
3694
|
+
setCucumberRunner: (v) => {
|
|
3695
|
+
self.#isCucumberRunner = v;
|
|
3696
|
+
},
|
|
3697
|
+
getRerunLabel: () => self.#getRerunLabel()
|
|
3698
|
+
};
|
|
3699
|
+
return this.#internals;
|
|
3700
|
+
}
|
|
3701
|
+
#handleReuseMode() {
|
|
3702
|
+
handleReuseMode(this.#getInternals());
|
|
3703
|
+
}
|
|
3704
|
+
async #openDevtoolsBrowser(url) {
|
|
3705
|
+
await openDevtoolsBrowser(this.#getInternals(), url);
|
|
3706
|
+
}
|
|
3707
|
+
async before() {
|
|
3708
|
+
const internals = this.#getInternals();
|
|
3709
|
+
internals.setConfigPath = (v) => {
|
|
3710
|
+
this.#configPath = v;
|
|
3711
|
+
};
|
|
3712
|
+
internals.openDevtoolsBrowserAt = (url) => this.#openDevtoolsBrowser(url);
|
|
3713
|
+
internals.handleReuse = () => this.#handleReuseMode();
|
|
3714
|
+
internals.plugin = this;
|
|
3715
|
+
await runPluginBefore(internals);
|
|
3716
|
+
}
|
|
3717
|
+
async #ensureSessionInitialized(browser) {
|
|
3718
|
+
await ensureSessionInitialized(
|
|
3719
|
+
this.#getInternals(),
|
|
3720
|
+
browser,
|
|
3721
|
+
() => this.#finalizeCurrentScreencast()
|
|
3722
|
+
);
|
|
3723
|
+
}
|
|
3724
|
+
async #finalizeCurrentScreencast() {
|
|
3725
|
+
await finalizeCurrentScreencast(this.#getInternals());
|
|
3726
|
+
}
|
|
3727
|
+
async cucumberBefore(browser, pickle) {
|
|
3728
|
+
await cucumberBefore(this.#getInternals(), browser, pickle);
|
|
3729
|
+
}
|
|
3730
|
+
async cucumberAfter(browser, result, pickle) {
|
|
3731
|
+
await cucumberAfter(this.#getInternals(), browser, result, pickle);
|
|
3732
|
+
}
|
|
3733
|
+
async cucumberBeforeStep(browser, pickleStep, pickle) {
|
|
3734
|
+
await cucumberBeforeStep(
|
|
3735
|
+
this.#getInternals(),
|
|
3736
|
+
browser,
|
|
3737
|
+
pickleStep,
|
|
3738
|
+
pickle
|
|
3739
|
+
);
|
|
3740
|
+
}
|
|
3741
|
+
async cucumberAfterStep(browser, result, pickleStep, pickle) {
|
|
3742
|
+
await cucumberAfterStep(
|
|
3743
|
+
this.#getInternals(),
|
|
3744
|
+
browser,
|
|
3745
|
+
result,
|
|
3746
|
+
pickleStep,
|
|
3747
|
+
pickle
|
|
3748
|
+
);
|
|
3749
|
+
}
|
|
3750
|
+
#resolveSuiteMetadata(currentTest) {
|
|
3751
|
+
return resolveSuiteMetadata(this.#getInternals(), currentTest);
|
|
3752
|
+
}
|
|
3753
|
+
#pickCurrentTestName(currentTest, testNames, processedTests) {
|
|
3754
|
+
return pickCurrentTestName(currentTest, testNames, processedTests);
|
|
3755
|
+
}
|
|
3756
|
+
async #startNextTest(currentSuite, currentTestName, processedTests) {
|
|
3757
|
+
await startNextTest(
|
|
3758
|
+
this.#getInternals(),
|
|
3759
|
+
currentSuite,
|
|
3760
|
+
currentTestName,
|
|
3761
|
+
processedTests
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3764
|
+
async #closePreviousRunningTest(currentSuite, testFile, currentTest) {
|
|
3765
|
+
await closePreviousRunningTest(
|
|
3766
|
+
this.#getInternals(),
|
|
3767
|
+
currentSuite,
|
|
3768
|
+
testFile,
|
|
3769
|
+
currentTest
|
|
3770
|
+
);
|
|
3771
|
+
}
|
|
3772
|
+
#wrapBrowserOnce(browser) {
|
|
3773
|
+
wrapBrowserOnce(this.#getInternals(), browser);
|
|
3774
|
+
}
|
|
3775
|
+
async beforeEach(browser) {
|
|
3776
|
+
if (this.#isCucumberRunner) {
|
|
3777
|
+
return;
|
|
3778
|
+
}
|
|
3779
|
+
await this.#ensureSessionInitialized(browser);
|
|
3780
|
+
const currentTest = browser.currentTest;
|
|
3781
|
+
if (!currentTest) {
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
const { testFile, fullPath, suiteTitle, testNames, suiteLine, testLines } = this.#resolveSuiteMetadata(currentTest);
|
|
3785
|
+
const currentSuite = this.suiteManager.getOrCreateSuite(
|
|
3786
|
+
testFile,
|
|
3787
|
+
suiteTitle,
|
|
3788
|
+
fullPath,
|
|
3789
|
+
testNames,
|
|
3790
|
+
suiteLine,
|
|
3791
|
+
testLines
|
|
3792
|
+
);
|
|
3793
|
+
if (fullPath && fullPath.includes("/")) {
|
|
3794
|
+
this.sessionCapturer.captureSource(fullPath).catch(() => {
|
|
3795
|
+
});
|
|
3796
|
+
}
|
|
3797
|
+
await this.#closePreviousRunningTest(currentSuite, testFile, currentTest);
|
|
3798
|
+
const processedTests = this.testManager.getProcessedTests(testFile);
|
|
3799
|
+
const currentTestName = this.#pickCurrentTestName(
|
|
3800
|
+
currentTest,
|
|
3801
|
+
testNames,
|
|
3802
|
+
processedTests
|
|
3803
|
+
);
|
|
3804
|
+
if (currentTestName) {
|
|
3805
|
+
await this.#startNextTest(currentSuite, currentTestName, processedTests);
|
|
3806
|
+
}
|
|
3807
|
+
this.#wrapBrowserOnce(browser);
|
|
3808
|
+
}
|
|
3809
|
+
async afterEach(browser) {
|
|
3810
|
+
if (this.#isCucumberRunner) {
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
if (browser && this.sessionCapturer) {
|
|
3814
|
+
try {
|
|
3815
|
+
await this.#closeOutTestcases(browser);
|
|
3816
|
+
await this.sessionCapturer.captureTrace(browser);
|
|
3817
|
+
} catch (err) {
|
|
3818
|
+
log11.error(`Failed to capture trace: ${errorMessage(err)}`);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
async #closeOutTestcases(browser) {
|
|
3823
|
+
await closeOutTestcases(this.#getInternals(), browser);
|
|
3824
|
+
}
|
|
3825
|
+
async after(browser) {
|
|
3826
|
+
await this.#finalizeCurrentScreencast();
|
|
3827
|
+
try {
|
|
3828
|
+
await this.#finalizeAllSuites(browser);
|
|
3829
|
+
this.#logRunSummary();
|
|
3830
|
+
if (!this.#devtoolsBrowser) {
|
|
3831
|
+
this.testReporter?.updateSuites();
|
|
3832
|
+
log11.info("\u267B Rerun complete \u2014 flushing WebSocket...");
|
|
3833
|
+
await this.sessionCapturer?.closeWebSocket();
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
log11.info("\u{1F4A1} Please close the DevTools browser window to finish...");
|
|
3837
|
+
await this.#waitForDevtoolsBrowserClose();
|
|
3838
|
+
} catch (err) {
|
|
3839
|
+
log11.error(`Failed to stop backend: ${errorMessage(err)}`);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
async #finalizeAllSuites(browser) {
|
|
3843
|
+
await finalizeAllSuites(this.#getInternals(), browser);
|
|
3844
|
+
}
|
|
3845
|
+
#logRunSummary() {
|
|
3846
|
+
logRunSummary(this.#getInternals());
|
|
3847
|
+
}
|
|
3848
|
+
async #waitForDevtoolsBrowserClose() {
|
|
3849
|
+
await waitForDevtoolsBrowserClose(this.#getInternals());
|
|
3850
|
+
}
|
|
3851
|
+
#buildMetadataOptions() {
|
|
3852
|
+
return buildPluginMetadataOptions({
|
|
3853
|
+
isCucumberRunner: this.#isCucumberRunner,
|
|
3854
|
+
configPath: this.#configPath
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
#incrementCount(state) {
|
|
3858
|
+
const counters = {
|
|
3859
|
+
passCount: this.#passCount,
|
|
3860
|
+
failCount: this.#failCount,
|
|
3861
|
+
skipCount: this.#skipCount
|
|
794
3862
|
};
|
|
3863
|
+
incrementCounters(counters, state);
|
|
3864
|
+
this.#passCount = counters.passCount;
|
|
3865
|
+
this.#failCount = counters.failCount;
|
|
3866
|
+
this.#skipCount = counters.skipCount;
|
|
3867
|
+
}
|
|
3868
|
+
#testIcon(state) {
|
|
3869
|
+
return getTestIcon(state);
|
|
3870
|
+
}
|
|
3871
|
+
registerEventHandlers(eventHub) {
|
|
3872
|
+
registerEventHandlers(eventHub, {
|
|
3873
|
+
getSessionCapturer: () => this.sessionCapturer,
|
|
3874
|
+
buildMetadataOptions: () => this.#buildMetadataOptions(),
|
|
3875
|
+
setCucumberRunner: (v) => {
|
|
3876
|
+
this.#isCucumberRunner = v;
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
var cucumberHooksPath = fileURLToPath(
|
|
3882
|
+
new URL("./helpers/cucumberHooks.cjs", import.meta.url)
|
|
3883
|
+
);
|
|
3884
|
+
function createNightwatchDevTools(options) {
|
|
3885
|
+
const plugin = new NightwatchDevToolsPlugin(options);
|
|
3886
|
+
return {
|
|
3887
|
+
// Set long timeout to allow user to review DevTools UI
|
|
3888
|
+
// The after() hook waits for the browser window to be closed
|
|
3889
|
+
asyncHookTimeout: 36e5,
|
|
3890
|
+
before: async function() {
|
|
3891
|
+
await plugin.before();
|
|
3892
|
+
},
|
|
3893
|
+
beforeEach: async function(browser) {
|
|
3894
|
+
await plugin.beforeEach(browser);
|
|
3895
|
+
},
|
|
3896
|
+
afterEach: async function(browser) {
|
|
3897
|
+
await plugin.afterEach(browser);
|
|
3898
|
+
},
|
|
3899
|
+
after: async function() {
|
|
3900
|
+
await plugin.after();
|
|
3901
|
+
},
|
|
3902
|
+
registerEventHandlers: function(eventHub) {
|
|
3903
|
+
plugin.registerEventHandlers(eventHub);
|
|
3904
|
+
}
|
|
3905
|
+
};
|
|
795
3906
|
}
|
|
796
|
-
export {
|
|
3907
|
+
export {
|
|
3908
|
+
NightwatchDevToolsPlugin,
|
|
3909
|
+
cucumberHooksPath,
|
|
3910
|
+
createNightwatchDevTools as default
|
|
3911
|
+
};
|
|
797
3912
|
//# sourceMappingURL=index.js.map
|