@wdio/selenium-devtools 0.0.1 → 1.0.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +15 -15
  3. package/dist/assertPatcher.d.ts +0 -11
  4. package/dist/assertPatcher.js +0 -123
  5. package/dist/assertPatcher.js.map +0 -1
  6. package/dist/bidi.d.ts +0 -6
  7. package/dist/bidi.js +0 -222
  8. package/dist/bidi.js.map +0 -1
  9. package/dist/constants.d.ts +0 -75
  10. package/dist/constants.js +0 -146
  11. package/dist/constants.js.map +0 -1
  12. package/dist/driverPatcher.d.ts +0 -4
  13. package/dist/driverPatcher.js +0 -256
  14. package/dist/driverPatcher.js.map +0 -1
  15. package/dist/helpers/detachedBackend.d.ts +0 -7
  16. package/dist/helpers/detachedBackend.js +0 -34
  17. package/dist/helpers/detachedBackend.js.map +0 -1
  18. package/dist/helpers/runtime.d.ts +0 -3
  19. package/dist/helpers/runtime.js +0 -47
  20. package/dist/helpers/runtime.js.map +0 -1
  21. package/dist/helpers/suiteManager.d.ts +0 -19
  22. package/dist/helpers/suiteManager.js +0 -131
  23. package/dist/helpers/suiteManager.js.map +0 -1
  24. package/dist/helpers/testManager.d.ts +0 -47
  25. package/dist/helpers/testManager.js +0 -158
  26. package/dist/helpers/testManager.js.map +0 -1
  27. package/dist/helpers/utils.d.ts +0 -26
  28. package/dist/helpers/utils.js +0 -187
  29. package/dist/helpers/utils.js.map +0 -1
  30. package/dist/helpers/videoEncoder.d.ts +0 -2
  31. package/dist/helpers/videoEncoder.js +0 -89
  32. package/dist/helpers/videoEncoder.js.map +0 -1
  33. package/dist/index.d.ts +0 -15
  34. package/dist/index.js +0 -801
  35. package/dist/index.js.map +0 -1
  36. package/dist/reporter.d.ts +0 -18
  37. package/dist/reporter.js +0 -72
  38. package/dist/reporter.js.map +0 -1
  39. package/dist/rerunManager.d.ts +0 -8
  40. package/dist/rerunManager.js +0 -78
  41. package/dist/rerunManager.js.map +0 -1
  42. package/dist/runnerHooks.d.ts +0 -6
  43. package/dist/runnerHooks.js +0 -594
  44. package/dist/runnerHooks.js.map +0 -1
  45. package/dist/screencast.d.ts +0 -11
  46. package/dist/screencast.js +0 -179
  47. package/dist/screencast.js.map +0 -1
  48. package/dist/session.d.ts +0 -48
  49. package/dist/session.js +0 -480
  50. package/dist/session.js.map +0 -1
  51. package/dist/setupConsole.d.ts +0 -1
  52. package/dist/setupConsole.js +0 -13
  53. package/dist/setupConsole.js.map +0 -1
  54. package/dist/types.d.ts +0 -235
  55. package/dist/types.js +0 -5
  56. package/dist/types.js.map +0 -1
package/dist/index.js DELETED
@@ -1,801 +0,0 @@
1
- // @wdio/selenium-devtools — runner-agnostic Selenium WebDriver adapter.
2
- // Side-effect import that patches selenium-webdriver and starts the backend.
3
- // MUST be the first import — see setupConsole.ts.
4
- import './setupConsole.js';
5
- import * as fs from 'node:fs';
6
- import * as path from 'node:path';
7
- import * as os from 'node:os';
8
- import { spawn } from 'node:child_process';
9
- import logger from '@wdio/logger';
10
- import { startDetachedBackend } from './helpers/detachedBackend.js';
11
- import { patchSelenium, getElementOriginals } from './driverPatcher.js';
12
- import { ensureBidiCapability, ensureHeadlessChrome, attachBidiHandlers, buildBidiSinks } from './bidi.js';
13
- import { SessionCapturer } from './session.js';
14
- import { TestReporter } from './reporter.js';
15
- import { SuiteManager } from './helpers/suiteManager.js';
16
- import { TestManager } from './helpers/testManager.js';
17
- import { RerunManager } from './rerunManager.js';
18
- import { ScreencastRecorder } from './screencast.js';
19
- import { encodeToVideo } from './helpers/videoEncoder.js';
20
- import { detectOwnVersion, detectRunner, detectSeleniumVersion } from './helpers/runtime.js';
21
- import { findFreePort, getCallSourceFromStack } from './helpers/utils.js';
22
- import { tryRegisterRunnerHooks } from './runnerHooks.js';
23
- import { patchNodeAssert } from './assertPatcher.js';
24
- import { DEFAULTS, REUSE_ENV, SCREENCAST_DEFAULTS, TIMING, TEST_STATE, NAVIGATION_COMMANDS } from './constants.js';
25
- import { TraceType } from './types.js';
26
- const log = logger('@wdio/selenium-devtools');
27
- const PLUGIN_VERSION = detectOwnVersion();
28
- const RUNNER = detectRunner();
29
- const SELENIUM_VERSION = detectSeleniumVersion() ?? 'unknown';
30
- log.info(`@wdio/selenium-devtools v${PLUGIN_VERSION} loaded`);
31
- log.info(`Workspace: ${process.cwd()}`);
32
- log.info(`Detected runner: ${RUNNER}`);
33
- log.info(`Detected selenium-webdriver: v${SELENIUM_VERSION}`);
34
- class SeleniumDevToolsPlugin {
35
- #options;
36
- #sessionCapturer;
37
- #testReporter;
38
- #suiteManager;
39
- #testManager;
40
- #rerunManager;
41
- #backendStarted = false;
42
- #backendStartPromise;
43
- #driver;
44
- #scriptInjected = false;
45
- #isReuse = false;
46
- // Coalesce internal retries: same {command,args,src} replaces prior entry.
47
- #lastCapturedSig = null;
48
- #lastCapturedId = null;
49
- #screencast;
50
- #screencastOptions;
51
- #sessionId;
52
- #uiUrlOpened = false;
53
- #testFileDir;
54
- #keepAliveTimer;
55
- #uiReadyPromise;
56
- // First it() body fires before onDriverCreated's async setup completes —
57
- // buffer startTest/endTest until testManager exists.
58
- #pendingTestActions = [];
59
- // Cucumber Before fires before the driver-build Before — stash and replay.
60
- #pendingScenario = null;
61
- constructor(options = {}) {
62
- this.#options = {
63
- port: options.port ?? 3000,
64
- hostname: options.hostname ?? 'localhost',
65
- // Default true to match @wdio/devtools-service and @wdio/nightwatch-devtools.
66
- openUi: options.openUi ?? true,
67
- captureScreenshots: options.captureScreenshots ?? true,
68
- rerunCommand: options.rerunCommand,
69
- headless: options.headless ?? false
70
- };
71
- this.#rerunManager = new RerunManager(RUNNER);
72
- if (options.rerunCommand) {
73
- this.#rerunManager.configure(options.rerunCommand);
74
- }
75
- this.#screencastOptions = {
76
- ...SCREENCAST_DEFAULTS,
77
- ...(options.screencast ?? {})
78
- };
79
- this.#detectReuseMode();
80
- }
81
- #configSummaryLogged = false;
82
- #logConfigSummary() {
83
- if (this.#configSummaryLogged) {
84
- return;
85
- }
86
- this.#configSummaryLogged = true;
87
- const screencast = this.#screencastOptions.enabled
88
- ? `${this.#screencastOptions.maxWidth}x${this.#screencastOptions.maxHeight}@q${this.#screencastOptions.quality}`
89
- : 'off';
90
- const rerun = this.#options.rerunCommand
91
- ? 'custom'
92
- : this.#rerunManager.rerunTemplate
93
- ? 'auto'
94
- : 'launch-only';
95
- log.info(`Configuration: openUi=${this.#options.openUi}, headless=${this.#options.headless}, ` +
96
- `screencast=${screencast}, captureScreenshots=${this.#options.captureScreenshots}, ` +
97
- `rerun=${rerun}`);
98
- }
99
- #detectReuseMode() {
100
- if (process.env[REUSE_ENV.REUSE] === '1' &&
101
- process.env[REUSE_ENV.HOST] &&
102
- process.env[REUSE_ENV.PORT]) {
103
- this.#options.hostname = process.env[REUSE_ENV.HOST];
104
- this.#options.port = Number(process.env[REUSE_ENV.PORT]);
105
- this.#isReuse = true;
106
- log.info(`♻ Reusing DevTools backend at ${this.#options.hostname}:${this.#options.port}`);
107
- }
108
- }
109
- async ensureBackendStarted() {
110
- if (this.#backendStarted) {
111
- return;
112
- }
113
- if (this.#backendStartPromise) {
114
- return this.#backendStartPromise;
115
- }
116
- this.#backendStartPromise = (async () => {
117
- try {
118
- this.#logConfigSummary();
119
- if (!this.#isReuse) {
120
- this.#options.port = await findFreePort(this.#options.port, this.#options.hostname);
121
- log.info('🚀 Starting DevTools backend...');
122
- const { port } = await startDetachedBackend({
123
- port: this.#options.port,
124
- hostname: this.#options.hostname
125
- });
126
- this.#options.port = port;
127
- log.info(`✓ Backend ready — DevTools UI: http://${this.#options.hostname}:${this.#options.port}`);
128
- }
129
- this.#backendStarted = true;
130
- // Skip when in REUSE mode — the rerun child reuses the parent's window.
131
- if (this.#options.openUi && !this.#isReuse) {
132
- this.#openUiWindow();
133
- }
134
- }
135
- catch (err) {
136
- log.error(`Failed to start backend: ${err.message}`);
137
- }
138
- })();
139
- return this.#backendStartPromise;
140
- }
141
- waitForUiReady() {
142
- if (this.#uiReadyPromise) {
143
- return this.#uiReadyPromise;
144
- }
145
- if (!this.#options.openUi) {
146
- this.#uiReadyPromise = Promise.resolve();
147
- return this.#uiReadyPromise;
148
- }
149
- const UI_READY_TIMEOUT_MS = 12000;
150
- this.#uiReadyPromise = (async () => {
151
- await this.ensureBackendStarted();
152
- if (!this.#sessionCapturer) {
153
- return;
154
- }
155
- log.info('⏳ Waiting for DevTools UI to connect…');
156
- const timer = new Promise((resolve) => setTimeout(resolve, UI_READY_TIMEOUT_MS));
157
- await Promise.race([this.#sessionCapturer.awaitClientConnected(), timer]);
158
- log.info('✓ DevTools UI ready — proceeding with tests');
159
- })();
160
- return this.#uiReadyPromise;
161
- }
162
- configure(opts = {}) {
163
- if ('rerunCommand' in opts) {
164
- this.#rerunManager.configure(opts.rerunCommand);
165
- this.#options.rerunCommand = opts.rerunCommand;
166
- }
167
- if (opts.screencast) {
168
- this.#screencastOptions = {
169
- ...this.#screencastOptions,
170
- ...opts.screencast
171
- };
172
- }
173
- if (typeof opts.headless === 'boolean') {
174
- this.#options.headless = opts.headless;
175
- }
176
- if (typeof opts.openUi === 'boolean') {
177
- this.#options.openUi = opts.openUi;
178
- }
179
- }
180
- get options() {
181
- return this.#options;
182
- }
183
- /** Public API: start a marked test. */
184
- startTest(name, meta = {}) {
185
- if (!this.#testFileDir && meta.file) {
186
- this.#testFileDir = path.dirname(meta.file);
187
- }
188
- const stackInfo = getCallSourceFromStack();
189
- const file = meta.file || stackInfo.filePath;
190
- const callSource = meta.callSource || stackInfo.callSource;
191
- const resolvedMeta = {};
192
- if (file) {
193
- resolvedMeta.file = file;
194
- }
195
- if (callSource && callSource !== 'unknown:0') {
196
- resolvedMeta.callSource = callSource;
197
- }
198
- if (!this.#suiteManager || !this.#testReporter) {
199
- this.#pendingTestActions.push({
200
- kind: 'start',
201
- name,
202
- meta: resolvedMeta,
203
- suiteName: meta.suiteName,
204
- suiteCallSource: meta.suiteCallSource
205
- });
206
- return;
207
- }
208
- this.#ensureSuiteAndTestManager(meta.suiteName ?? DEFAULTS.SESSION_TITLE, meta.suiteCallSource);
209
- if (meta.suiteName || meta.suiteCallSource) {
210
- this.#suiteManager.setRootSuiteTitle(meta.suiteName ?? '', meta.suiteCallSource);
211
- }
212
- this.#testManager.startMarkedTest(name, resolvedMeta);
213
- this.#lastCapturedSig = null;
214
- this.#lastCapturedId = null;
215
- if (file) {
216
- this.#sessionCapturer?.captureSource(file).catch(() => { });
217
- }
218
- }
219
- endTest(state = 'passed') {
220
- if (!this.#testManager) {
221
- this.#pendingTestActions.push({ kind: 'end', state });
222
- return;
223
- }
224
- this.#testManager.endCurrent(state);
225
- }
226
- /** Cucumber scenario boundary — opens a sub-suite under the feature root. */
227
- startScenario(name, meta = {}) {
228
- if (!this.#suiteManager || !this.#testReporter) {
229
- this.#pendingScenario = { name, ...meta };
230
- return;
231
- }
232
- this.#ensureSuiteAndTestManager(meta.featureName ?? DEFAULTS.SESSION_TITLE, meta.featureCallSource);
233
- if (meta.featureName || meta.featureCallSource) {
234
- this.#suiteManager.setRootSuiteTitle(meta.featureName ?? '', meta.featureCallSource);
235
- }
236
- const file = meta.file ?? this.#suiteManager.getRootSuite()?.file ?? process.cwd();
237
- this.#suiteManager.startScenarioSuite(name, file, meta.callSource);
238
- this.#lastCapturedSig = null;
239
- this.#lastCapturedId = null;
240
- if (meta.file) {
241
- this.#sessionCapturer?.captureSource(meta.file).catch(() => { });
242
- }
243
- }
244
- endScenario(state = 'passed') {
245
- if (!this.#suiteManager) {
246
- return;
247
- }
248
- this.#testManager?.endCurrent(state);
249
- this.#suiteManager.endScenarioSuite(state);
250
- this.#lastCapturedSig = null;
251
- this.#lastCapturedId = null;
252
- }
253
- /** Lazy-create rootSuite + testManager so they take the real describe title. */
254
- #ensureSuiteAndTestManager(title, callSource) {
255
- if (!this.#suiteManager || !this.#testReporter) {
256
- return;
257
- }
258
- let rootSuite = this.#suiteManager.getRootSuite();
259
- const created = !rootSuite;
260
- if (!rootSuite) {
261
- const effectiveTitle = this.#pendingScenario?.featureName ?? title;
262
- rootSuite = this.#suiteManager.getOrCreateRootSuite(process.cwd(), effectiveTitle);
263
- const cs = this.#pendingScenario?.featureCallSource ?? callSource;
264
- if (cs) {
265
- rootSuite.callSource = cs;
266
- }
267
- }
268
- if (!this.#testManager) {
269
- this.#testManager = new TestManager(rootSuite, this.#testReporter, this.#suiteManager);
270
- }
271
- if (created && this.#pendingScenario) {
272
- const p = this.#pendingScenario;
273
- this.#pendingScenario = null;
274
- const file = p.file ?? rootSuite.file;
275
- this.#suiteManager.startScenarioSuite(p.name, file, p.callSource);
276
- if (p.file) {
277
- this.#sessionCapturer?.captureSource(p.file).catch(() => { });
278
- }
279
- }
280
- }
281
- /** Apply any startTest/endTest calls buffered before testManager existed. */
282
- #flushPendingTestActions() {
283
- if (this.#pendingTestActions.length === 0) {
284
- return;
285
- }
286
- for (const action of this.#pendingTestActions) {
287
- if (action.kind === 'start') {
288
- this.#ensureSuiteAndTestManager(action.suiteName ?? DEFAULTS.SESSION_TITLE, action.suiteCallSource);
289
- if (!this.#testManager) {
290
- continue;
291
- }
292
- if (action.suiteName || action.suiteCallSource) {
293
- this.#suiteManager?.setRootSuiteTitle(action.suiteName ?? '', action.suiteCallSource);
294
- }
295
- this.#testManager.startMarkedTest(action.name, action.meta);
296
- if (action.meta.file) {
297
- this.#sessionCapturer?.captureSource(action.meta.file).catch(() => { });
298
- }
299
- }
300
- else {
301
- this.#testManager?.endCurrent(action.state);
302
- }
303
- }
304
- this.#pendingTestActions = [];
305
- }
306
- async onDriverCreated(driver) {
307
- const driverReadyTs = Date.now();
308
- await this.ensureBackendStarted();
309
- if (this.#driver === driver) {
310
- return;
311
- }
312
- // Fresh-driver-per-test: re-target capturer; reuse suite/reporter/testManager.
313
- if (this.#driver || this.#sessionCapturer) {
314
- log.info('New driver detected — re-targeting capturer for next test');
315
- this.#driver = driver;
316
- this.#sessionCapturer?.setDriver(driver);
317
- await this.#initPerDriverCapture(driver, driverReadyTs);
318
- return;
319
- }
320
- this.#driver = driver;
321
- this.#sessionCapturer = new SessionCapturer({ hostname: this.#options.hostname, port: this.#options.port }, driver);
322
- // Dashboard closed AFTER tests finished → wind the runner down so the
323
- // user doesn't have to Ctrl+C. Ignore during a live run: a momentary
324
- // reconnect blip during tests must not abort them.
325
- this.#sessionCapturer.setClientDisconnectedHandler(() => {
326
- if (this.finalized) {
327
- void gracefulShutdown(0);
328
- }
329
- });
330
- await this.#sessionCapturer.waitForConnection(TIMING.UI_CONNECTION_WAIT);
331
- this.#testReporter = new TestReporter((suitesData) => {
332
- this.#sessionCapturer?.sendUpstream('suites', suitesData);
333
- });
334
- this.#suiteManager = new SuiteManager(this.#testReporter);
335
- this.#flushPendingTestActions();
336
- await this.#initPerDriverCapture(driver, driverReadyTs);
337
- }
338
- async #initPerDriverCapture(driver, driverReadyTs) {
339
- if (!this.#sessionCapturer) {
340
- return;
341
- }
342
- try {
343
- const session = driver.getSession ? await driver.getSession() : undefined;
344
- const capabilities = driver.getCapabilities
345
- ? await driver.getCapabilities()
346
- : undefined;
347
- this.#sessionId = session?.getId?.() ?? undefined;
348
- const capGet = (k) => {
349
- if (capabilities?.get && typeof capabilities.get === 'function') {
350
- return capabilities.get(k);
351
- }
352
- const serialized = capabilities?.serialize?.() ?? capabilities ?? {};
353
- return serialized[k];
354
- };
355
- const browserName = capGet('browserName') ?? 'unknown';
356
- const browserVersion = capGet('browserVersion') ?? capGet('version') ?? '';
357
- const platform = capGet('platformName') ?? capGet('platform') ?? '';
358
- log.info(`🌐 Browser: ${browserName}${browserVersion ? ' ' + browserVersion : ''}${platform ? ' on ' + platform : ''} (sessionId: ${this.#sessionId ?? 'unknown'})`);
359
- const webSocketUrl = capGet('webSocketUrl');
360
- const chromeOpts = capGet('goog:chromeOptions') ?? {};
361
- const chromeArgs = Array.isArray(chromeOpts?.args)
362
- ? chromeOpts.args
363
- : [];
364
- const headlessArg = chromeArgs.find((a) => a.startsWith('--headless'));
365
- log.info(`📋 Capabilities sent: browserName=${browserName}, webSocketUrl=${webSocketUrl ? 'on' : 'off'}` +
366
- (headlessArg ? `, ${headlessArg}` : '') +
367
- (chromeArgs.length ? `, chromeArgs=${chromeArgs.length}` : ''));
368
- log.info(`Driver session created in ${Date.now() - driverReadyTs}ms`);
369
- this.#sessionCapturer.sendUpstream('metadata', {
370
- type: TraceType.Testrunner,
371
- capabilities: capabilities?.serialize?.() ?? capabilities ?? {},
372
- sessionId: this.#sessionId,
373
- options: {
374
- framework: 'selenium-webdriver',
375
- baseDir: process.cwd(),
376
- rerunCommand: this.#options.rerunCommand ?? this.#rerunManager.rerunTemplate,
377
- launchCommand: this.#rerunManager.launchCommand,
378
- // Cucumber `--name` filters scenarios but not Gherkin steps, so
379
- // leaf-step rerun stays disabled there.
380
- runCapabilities: {
381
- canRunSuites: true,
382
- canRunTests: RUNNER !== 'cucumber',
383
- canRunAll: true
384
- }
385
- }
386
- });
387
- }
388
- catch (err) {
389
- log.warn(`Failed to send metadata: ${err.message}`);
390
- }
391
- // Parallel — serial attach misses frames on fast tests.
392
- const screencastPromise = this.#screencastOptions.enabled
393
- ? (async () => {
394
- try {
395
- this.#screencast = new ScreencastRecorder(this.#screencastOptions);
396
- await this.#screencast.start(driver);
397
- }
398
- catch (err) {
399
- log.warn(`Screencast start failed: ${err.message}`);
400
- }
401
- })()
402
- : Promise.resolve();
403
- const bidiPromise = (async () => {
404
- try {
405
- const sinks = buildBidiSinks(this.#sessionCapturer);
406
- const ok = await attachBidiHandlers(driver, sinks);
407
- if (ok) {
408
- this.#sessionCapturer.bidiActive = true;
409
- log.info('✓ BiDi data flow active — script-injected console/network suppressed');
410
- }
411
- }
412
- catch (err) {
413
- log.warn(`BiDi attach threw: ${err.message}`);
414
- }
415
- })();
416
- await Promise.all([screencastPromise, bidiPromise]);
417
- }
418
- async onCommand(cmd) {
419
- const capturer = this.#sessionCapturer;
420
- const testManager = this.#testManager;
421
- if (!capturer || !testManager) {
422
- return;
423
- }
424
- const test = testManager.getOrEnsureTest();
425
- if (!test) {
426
- return;
427
- }
428
- const error = cmd.error && cmd.error instanceof Error
429
- ? cmd.error
430
- : cmd.error
431
- ? new Error(String(cmd.error))
432
- : undefined;
433
- const cmdSig = JSON.stringify({
434
- command: cmd.command,
435
- args: cmd.args,
436
- src: cmd.callSource ?? null
437
- });
438
- const isRetry = this.#lastCapturedSig === cmdSig && this.#lastCapturedId !== null;
439
- let entry;
440
- if (isRetry) {
441
- const replaced = capturer.replaceCommand(this.#lastCapturedId, cmd.command, cmd.args.map((a) => a), error ? undefined : cmd.result, error, test.uid, cmd.callSource, cmd.timestamp);
442
- entry = replaced.entry;
443
- this.#lastCapturedId = entry._id ?? null;
444
- capturer.sendReplaceCommand(replaced.oldTimestamp, entry);
445
- }
446
- else {
447
- entry = (await capturer.captureCommand(cmd.command, cmd.args, cmd.result, error, test.uid, cmd.callSource, cmd.timestamp));
448
- capturer.sendCommand(entry);
449
- this.#lastCapturedSig = cmdSig;
450
- this.#lastCapturedId = entry._id ?? null;
451
- }
452
- if (this.#options.captureScreenshots && !error) {
453
- const ts = entry.timestamp;
454
- capturer
455
- .takeScreenshot()
456
- .then((shot) => {
457
- if (shot) {
458
- entry.screenshot = shot;
459
- capturer.sendReplaceCommand(ts, entry);
460
- }
461
- })
462
- .catch(() => { });
463
- }
464
- // Enrich opaque WebElement results with tag + text preview for the UI.
465
- if (!error &&
466
- cmd.rawResult &&
467
- (cmd.command === 'findElement' || cmd.command === 'findElements')) {
468
- const ts = entry.timestamp;
469
- void this.#enrichFindResult(cmd.rawResult, entry, ts);
470
- }
471
- if (capturer.isNavigationCommand(cmd.command) && !cmd.fromElement) {
472
- void (async () => {
473
- try {
474
- if (!this.#scriptInjected) {
475
- this.#scriptInjected = true;
476
- await capturer.injectScript();
477
- }
478
- await capturer.captureTrace();
479
- if (!capturer.bidiActive) {
480
- await capturer.captureBrowserLogs();
481
- }
482
- }
483
- catch (err) {
484
- if (!this.#finalized) {
485
- log.warn(`Trace capture failed: ${err.message}`);
486
- }
487
- }
488
- })();
489
- }
490
- }
491
- async #enrichFindResult(rawResult, entry, ts) {
492
- const capturer = this.#sessionCapturer;
493
- if (!capturer) {
494
- return;
495
- }
496
- // Unwrapped methods so these probes don't appear as phantom commands.
497
- const els = getElementOriginals();
498
- const getTagName = els.getTagName;
499
- const getText = els.getText;
500
- if (!getTagName || !getText) {
501
- return;
502
- }
503
- try {
504
- const elements = Array.isArray(rawResult) ? rawResult : [rawResult];
505
- const previews = await Promise.all(elements.slice(0, 5).map(async (el) => {
506
- const tag = await getTagName(el).catch(() => 'element');
507
- const text = await getText(el).catch(() => '');
508
- const trimmed = text.length > 60 ? text.slice(0, 60) + '…' : text;
509
- return trimmed ? `<${tag}>"${trimmed}"` : `<${tag}>`;
510
- }));
511
- const more = elements.length > 5 ? `, +${elements.length - 5} more` : '';
512
- const enriched = Array.isArray(rawResult)
513
- ? `[${previews.join(', ')}${more}]`
514
- : previews[0];
515
- entry.result = enriched;
516
- capturer.sendReplaceCommand(ts, entry);
517
- }
518
- catch {
519
- // Element detached / stale — leave the original `<WebElement>` text.
520
- }
521
- }
522
- // `open` merges windows into an existing Chrome process and loses
523
- // `--user-data-dir` isolation, so we spawn the binary directly.
524
- #findChromeBinary() {
525
- const candidates = process.platform === 'darwin'
526
- ? [
527
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
528
- '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
529
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
530
- `${os.homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
531
- ]
532
- : process.platform === 'win32'
533
- ? [
534
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
535
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
536
- `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`
537
- ]
538
- : [
539
- '/usr/bin/google-chrome',
540
- '/usr/bin/google-chrome-stable',
541
- '/usr/bin/chromium-browser',
542
- '/usr/bin/chromium'
543
- ];
544
- for (const c of candidates) {
545
- if (c && fs.existsSync(c)) {
546
- return c;
547
- }
548
- }
549
- return null;
550
- }
551
- #openUiWindow() {
552
- if (this.#uiUrlOpened) {
553
- return;
554
- }
555
- this.#uiUrlOpened = true;
556
- const url = `http://${this.#options.hostname}:${this.#options.port}`;
557
- const chromeBin = this.#findChromeBinary();
558
- if (!chromeBin) {
559
- log.warn(`Chrome binary not found. Open manually: ${url}`);
560
- return;
561
- }
562
- const userDataDir = path.join(os.tmpdir(), `selenium-devtools-ui-${this.#options.port}-${Date.now()}`);
563
- log.info(`Chrome binary: ${chromeBin}`);
564
- log.info(`💡 Opening DevTools UI: ${url}`);
565
- const chromeArgs = [
566
- `--user-data-dir=${userDataDir}`,
567
- '--no-first-run',
568
- '--no-default-browser-check',
569
- '--window-size=1600,1200',
570
- '--new-window',
571
- url
572
- ];
573
- try {
574
- // Double-fork: a short-lived Node intermediate spawns Chrome detached
575
- // and exits, so Chrome is reparented to launchd/init and survives any
576
- // tree-kill the test runner does on its descendants (vitest's pool,
577
- // jest --forceExit, mocha SIGINT). Same path for every runner.
578
- const code = 'require("child_process")' +
579
- `.spawn(${JSON.stringify(chromeBin)}, ${JSON.stringify(chromeArgs)}, { detached: true, stdio: "ignore" }).unref()`;
580
- const intermediate = spawn(process.execPath, ['-e', code], {
581
- detached: true,
582
- stdio: 'ignore'
583
- });
584
- intermediate.unref();
585
- intermediate.on('error', (err) => {
586
- log.warn(`Could not auto-open DevTools UI (${err.message}). Open manually: ${url}`);
587
- });
588
- }
589
- catch (err) {
590
- log.warn(`Could not auto-open DevTools UI (${err.message}). Open manually: ${url}`);
591
- }
592
- }
593
- #finalized = false;
594
- get finalized() {
595
- return this.#finalized;
596
- }
597
- /** Per-driver cleanup; keeps capturer/suite/testManager/backend alive. */
598
- async onDriverEnd() {
599
- if (this.#screencast) {
600
- try {
601
- await this.#screencast.stop();
602
- const frames = this.#screencast.frames;
603
- if (frames.length > 0 && this.#sessionId) {
604
- const fileName = `selenium-video-${this.#sessionId}.webm`;
605
- // Output dir priority: test-file dir → cwd → os.tmpdir().
606
- const candidate = this.#testFileDir || process.cwd();
607
- let videoPath = path.join(candidate, fileName);
608
- try {
609
- fs.accessSync(candidate, fs.constants.W_OK);
610
- }
611
- catch {
612
- videoPath = path.join(os.tmpdir(), fileName);
613
- }
614
- try {
615
- await encodeToVideo(frames, videoPath, {
616
- captureFormat: this.#screencastOptions.captureFormat
617
- });
618
- log.info(`📹 Screencast video: ${videoPath}`);
619
- this.#sessionCapturer?.sendUpstream('screencast', {
620
- sessionId: this.#sessionId,
621
- videoPath,
622
- videoFile: fileName,
623
- frameCount: frames.length
624
- });
625
- }
626
- catch (err) {
627
- log.warn(`Screencast encode failed: ${err.message}`);
628
- }
629
- }
630
- }
631
- catch (err) {
632
- log.warn(`Screencast stop failed: ${err.message}`);
633
- }
634
- }
635
- this.#driver = undefined;
636
- this.#screencast = undefined;
637
- this.#scriptInjected = false;
638
- this.#sessionId = undefined;
639
- this.#lastCapturedSig = null;
640
- this.#lastCapturedId = null;
641
- }
642
- /** Final teardown. Idempotent. */
643
- async onSessionEnd() {
644
- if (this.#finalized) {
645
- return;
646
- }
647
- this.#finalized = true;
648
- const shutdownStart = Date.now();
649
- try {
650
- await this.onDriverEnd().catch(() => { });
651
- this.#testManager?.finalizeSession();
652
- this.#suiteManager?.finalize();
653
- this.#testReporter?.updateSuites();
654
- const cmdCount = this.#sessionCapturer?.commandsLog.length ?? 0;
655
- const consoleCount = this.#sessionCapturer?.consoleLogs.length ?? 0;
656
- const networkCount = this.#sessionCapturer?.networkRequests.length ?? 0;
657
- log.info(`📊 Session summary — ${cmdCount} command(s), ${networkCount} network request(s), ${consoleCount} console log(s)`);
658
- this.#sessionCapturer?.cleanup();
659
- // Keep the worker WS open while the dashboard is up — it's the
660
- // channel the backend uses to tell us "the user closed the
661
- // dashboard, time to exit". gracefulShutdown closes it on real exit.
662
- if (!this.#options.openUi || this.#isReuse) {
663
- await this.#sessionCapturer?.closeWebSocket();
664
- }
665
- if (this.#options.openUi && !this.#isReuse) {
666
- log.info(`💡 Tests complete — DevTools UI: http://${this.#options.hostname}:${this.#options.port}`);
667
- }
668
- log.info(`🛑 Shutdown complete (${Date.now() - shutdownStart}ms)`);
669
- }
670
- catch (err) {
671
- log.warn(`Cleanup error: ${err.message}`);
672
- }
673
- }
674
- async onProcessExit() {
675
- return this.onSessionEnd();
676
- }
677
- /** Mark suite finished on after-all so the dashboard updates pre-exit. */
678
- finalizeTestRun() {
679
- this.#testManager?.finalizeSession();
680
- this.#suiteManager?.finalize();
681
- this.#testReporter?.updateSuites();
682
- }
683
- get sessionCapturer() {
684
- return this.#sessionCapturer;
685
- }
686
- get rerunManager() {
687
- return this.#rerunManager;
688
- }
689
- clearKeepAlive() {
690
- if (this.#keepAliveTimer) {
691
- clearInterval(this.#keepAliveTimer);
692
- this.#keepAliveTimer = undefined;
693
- }
694
- }
695
- }
696
- const plugin = new SeleniumDevToolsPlugin();
697
- const patched = patchSelenium({
698
- onBeforeBuild: (builder) => {
699
- ensureBidiCapability(builder);
700
- if (plugin.options.headless) {
701
- ensureHeadlessChrome(builder);
702
- }
703
- },
704
- onDriverCreated: (driver) => plugin.onDriverCreated(driver),
705
- onCommand: (cmd) => plugin.onCommand(cmd),
706
- onBeforeQuit: () => plugin.onSessionEnd(),
707
- // Block `await Builder.build()` until the dashboard is connected.
708
- waitForReady: () => plugin.waitForUiReady()
709
- });
710
- if (patched) {
711
- log.info('✓ selenium-devtools attached — waiting for driver creation');
712
- }
713
- // node:assert wrappers silently invert match/doesNotMatch — kept disabled.
714
- void patchNodeAssert;
715
- // Runner globals are published after `--require`, so retry briefly.
716
- function registerHooks() {
717
- return tryRegisterRunnerHooks({
718
- onTestStart: (name, file, callSource, suiteName, suiteCallSource) => {
719
- const meta = {};
720
- if (file) {
721
- meta.file = file;
722
- }
723
- if (callSource) {
724
- meta.callSource = callSource;
725
- }
726
- if (suiteName) {
727
- meta.suiteName = suiteName;
728
- }
729
- if (suiteCallSource) {
730
- meta.suiteCallSource = suiteCallSource;
731
- }
732
- plugin.startTest(name, meta);
733
- },
734
- onTestEnd: (state) => {
735
- plugin.endTest(state === 'pending' ? 'skipped' : state);
736
- },
737
- onScenarioStart: (name, file, callSource, featureName, featureCallSource) => {
738
- plugin.startScenario(name, {
739
- file,
740
- callSource,
741
- featureName,
742
- featureCallSource
743
- });
744
- },
745
- onScenarioEnd: (state) => {
746
- plugin.endScenario(state === 'pending' ? 'skipped' : state);
747
- },
748
- onTestRunComplete: () => {
749
- plugin.finalizeTestRun();
750
- }
751
- });
752
- }
753
- if (!registerHooks()) {
754
- let attempts = 0;
755
- const interval = setInterval(() => {
756
- attempts++;
757
- if (registerHooks() || attempts >= 20) {
758
- clearInterval(interval);
759
- }
760
- }, 100);
761
- }
762
- process.on('exit', () => {
763
- void plugin.onSessionEnd();
764
- });
765
- process.on('beforeExit', () => {
766
- void plugin.onSessionEnd();
767
- });
768
- async function gracefulShutdown(code) {
769
- try {
770
- plugin.clearKeepAlive();
771
- await plugin.sessionCapturer?.closeWebSocket();
772
- plugin.sessionCapturer?.cleanup();
773
- // Best-effort: kill the detached Chrome dashboard. Each session's
774
- // --user-data-dir contains the unique `selenium-devtools-ui-${port}`
775
- // marker, so a pattern match lands on this run's window only.
776
- try {
777
- spawn('/usr/bin/pkill', ['-f', `selenium-devtools-ui-${plugin.options.port}-`], { stdio: 'ignore' });
778
- }
779
- catch {
780
- /* pkill missing — accept stale Chrome */
781
- }
782
- }
783
- catch {
784
- /* best-effort */
785
- }
786
- process.exit(code);
787
- }
788
- process.on('SIGINT', () => {
789
- void gracefulShutdown(130);
790
- });
791
- process.on('SIGTERM', () => {
792
- void gracefulShutdown(143);
793
- });
794
- export const DevTools = {
795
- configure: (opts) => plugin.configure(opts),
796
- startTest: (name, meta) => plugin.startTest(name, meta),
797
- endTest: (state = 'passed') => plugin.endTest(state)
798
- };
799
- export default DevTools;
800
- export { TEST_STATE, NAVIGATION_COMMANDS };
801
- //# sourceMappingURL=index.js.map