Skip to content

Commit 2b01d71

Browse files
vishnuv688claude
andcommitted
Move duplicated adapter code into core
The same logic sat in two or three adapters word for word, so a change to it had to land in every copy, which is CLAUDE.md's rule for core: - The screencast recorder's start, stop and unavailable log lines were written by each adapter's subclass; ScreencastRecorderBase now writes them through a logger each adapter supplies, keeping its logger name. - Selenium and Nightwatch built their BiDi console and network sinks the same way; buildBidiSinks is now in core, with Selenium's console evidence latch passed in as a hook, since only Selenium has one. Within one adapter: Selenium's mocha, cucumber and jest hooks share one run summary; Selenium's and Nightwatch's PluginInternals extend the context types they repeated; Nightwatch builds its captured and replaced command rows through one helper; the WDIO launcher and standalone mode share their argv parsing, in a module of its own so the launcher's rerun patch never reaches the worker bundle. Kept: each adapter's attachBidiHandlers only binds its own logger name, and the types.ts files re-export shared types under stable local names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aea93af commit 2b01d71

24 files changed

Lines changed: 422 additions & 438 deletions

‎packages/core/src/bidi.ts‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,50 @@ import {
1212
import { errorMessage } from './error.js'
1313
import { getRequestType } from './net.js'
1414

15-
/**
16-
* Generic sinks the BiDi handlers push into. Each adapter wires these to its
17-
* own SessionCapturer state — selenium's `buildBidiSinks` is the canonical
18-
* example; nightwatch can mirror the pattern when it wires up BiDi.
19-
*/
15+
/** Generic sinks the BiDi handlers push into; {@link buildBidiSinks} wires them to a capturer. */
2016
export interface BidiHandlerSinks {
2117
pushConsoleLog: (entry: ConsoleLog) => void
2218
pushNetworkRequest: (entry: NetworkRequest) => void
2319
replaceNetworkRequest: (id: string, entry: NetworkRequest) => void
2420
}
2521

22+
/** The capturer state BiDi events land in; `bidiNetworkObserved` is the evidence latch that gates the page's own rows. */
23+
export interface BidiSinkTarget {
24+
consoleLogs: ConsoleLog[]
25+
networkRequests: NetworkRequest[]
26+
sendUpstream(event: string, data: unknown): void
27+
bidiNetworkObserved: boolean
28+
}
29+
30+
/** Route BiDi events into the target's arrays and upstream; `onConsoleEntry` runs before each console entry is kept. */
31+
export function buildBidiSinks(
32+
target: BidiSinkTarget,
33+
onConsoleEntry?: () => void
34+
): BidiHandlerSinks {
35+
return {
36+
pushConsoleLog: (entry) => {
37+
onConsoleEntry?.()
38+
target.consoleLogs.push(entry)
39+
target.sendUpstream('consoleLogs', [entry])
40+
},
41+
pushNetworkRequest: (entry) => {
42+
target.bidiNetworkObserved = true
43+
target.networkRequests.push(entry)
44+
target.sendUpstream('networkRequests', [entry])
45+
},
46+
replaceNetworkRequest: (id, entry) => {
47+
target.bidiNetworkObserved = true
48+
const idx = target.networkRequests.findIndex((r) => r.id === id)
49+
if (idx !== -1) {
50+
target.networkRequests[idx] = entry
51+
} else {
52+
target.networkRequests.push(entry)
53+
}
54+
target.sendUpstream('networkRequests', [entry])
55+
}
56+
}
57+
}
58+
2659
/**
2760
* Resolve a `selenium-webdriver/<subpath>` module from the user's install
2861
* (preferred) or the package's local install (fallback). Returns `null` if

‎packages/core/src/screencast.ts‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared'
22
import { SCREENCAST_DEFAULTS } from '@wdio/devtools-shared'
33
import { isInputDispatchInFlight } from './input-dispatch.js'
44
import { withTimeout } from './with-timeout.js'
5+
import { errorMessage } from './error.js'
56

67
/** Ceiling for one awaited driver primitive in the screencast start/stop
78
* handshake. The queue serialises start against stop, so a primitive that
@@ -10,6 +11,12 @@ export const SCREENCAST_HANDSHAKE_TIMEOUT_MS = 5000
1011

1112
const FIRST_SHOT_TIMEOUT = Symbol('first-shot-timeout')
1213

14+
/** Where a recorder reports its lifecycle; each adapter passes its own logger. */
15+
export interface ScreencastLogger {
16+
info(message: string): void
17+
warn(message: string): void
18+
}
19+
1320
/**
1421
* Shared screencast scaffolding consumed by every adapter (service, selenium,
1522
* nightwatch). Owns the frame buffer, public API (start/stop/setStartMarker,
@@ -235,22 +242,26 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {
235242

236243
// ─── Polling implementation ─────────────────────────────────────────────
237244

238-
/**
239-
* Hook fired when the polling loop starts. Default: no-op. Subclasses
240-
* (adapters with their own logger) override to surface visibility.
241-
*/
242-
protected onPollingStarted(_intervalMs: number): void {
243-
// no-op
245+
/** The adapter's logger for the hooks below; none keeps them silent. */
246+
protected readonly logger: ScreencastLogger | undefined = undefined
247+
248+
/** Hook fired when the polling loop starts. */
249+
protected onPollingStarted(intervalMs: number): void {
250+
this.logger?.info(
251+
`✓ Screencast recording started (polling mode, ${intervalMs} ms interval)`
252+
)
244253
}
245254

246255
/** Hook fired when polling stops cleanly (driver still alive at the time). */
247-
protected onPollingStopped(_frameCount: number): void {
248-
// no-op
256+
protected onPollingStopped(frameCount: number): void {
257+
this.logger?.info(`✓ Screencast stopped — ${frameCount} frame(s) collected`)
249258
}
250259

251260
/** Hook fired when the polling fallback couldn't even take the first shot. */
252-
protected onUnavailable(_err: unknown): void {
253-
// no-op
261+
protected onUnavailable(err: unknown): void {
262+
this.logger?.warn(
263+
`Screencast unavailable (${errorMessage(err)}). Recording skipped.`
264+
)
254265
}
255266

256267
// ─── Polling implementation ─────────────────────────────────────────────

‎packages/core/tests/bidi.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
22
import {
33
arrayHeadersToObject,
44
attachBidiHandlers,
5+
buildBidiSinks,
6+
type BidiSinkTarget,
57
loadSeleniumSubmodule,
68
handleBidiConsoleEntry,
79
handleBidiJsException,
@@ -670,3 +672,72 @@ describe('attachNetworkInspector with an inspector that has a fetchError hook',
670672
expect(replacements.at(-1)?.entry).toMatchObject({ id: 'open', status: 0 })
671673
})
672674
})
675+
676+
describe('buildBidiSinks', () => {
677+
function makeTarget() {
678+
const sent: Array<[string, unknown]> = []
679+
const target: BidiSinkTarget = {
680+
consoleLogs: [],
681+
networkRequests: [],
682+
bidiNetworkObserved: false,
683+
sendUpstream: (event, data) => sent.push([event, data])
684+
}
685+
return { target, sent }
686+
}
687+
const request = (id: string, url = 'https://x/'): NetworkRequest => ({
688+
id,
689+
url,
690+
method: 'GET',
691+
type: 'document',
692+
timestamp: 1,
693+
startTime: 0
694+
})
695+
696+
it('keeps and forwards a console entry, calling the hook first', () => {
697+
const { target, sent } = makeTarget()
698+
const seen: number[] = []
699+
const sinks = buildBidiSinks(target, () =>
700+
seen.push(target.consoleLogs.length)
701+
)
702+
const entry = { type: 'log' as const, args: ['hi'], timestamp: 1 }
703+
sinks.pushConsoleLog(entry)
704+
expect(seen).toEqual([0])
705+
expect(target.consoleLogs).toEqual([entry])
706+
expect(sent).toEqual([['consoleLogs', [entry]]])
707+
expect(target.bidiNetworkObserved).toBe(false)
708+
})
709+
710+
it('accepts a console entry without a hook', () => {
711+
const { target } = makeTarget()
712+
buildBidiSinks(target).pushConsoleLog({
713+
type: 'log',
714+
args: ['hi'],
715+
timestamp: 1
716+
})
717+
expect(target.consoleLogs).toHaveLength(1)
718+
})
719+
720+
it('latches network evidence on a pushed request', () => {
721+
const { target, sent } = makeTarget()
722+
buildBidiSinks(target).pushNetworkRequest(request('a'))
723+
expect(target.bidiNetworkObserved).toBe(true)
724+
expect(target.networkRequests.map((r) => r.id)).toEqual(['a'])
725+
expect(sent).toEqual([['networkRequests', [request('a')]]])
726+
})
727+
728+
it('replaces a known request in place and appends an unknown one', () => {
729+
const { target } = makeTarget()
730+
const sinks = buildBidiSinks(target)
731+
sinks.pushNetworkRequest(request('a'))
732+
sinks.pushNetworkRequest(request('b'))
733+
target.bidiNetworkObserved = false
734+
sinks.replaceNetworkRequest('a', request('a', 'https://done/'))
735+
sinks.replaceNetworkRequest('c', request('c'))
736+
expect(target.bidiNetworkObserved).toBe(true)
737+
expect(target.networkRequests.map((r) => [r.id, r.url])).toEqual([
738+
['a', 'https://done/'],
739+
['b', 'https://x/'],
740+
['c', 'https://x/']
741+
])
742+
})
743+
})

‎packages/core/tests/screencast.test.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { ScreencastRecorderBase } from '../src/screencast.js'
2+
import {
3+
ScreencastRecorderBase,
4+
type ScreencastLogger
5+
} from '../src/screencast.js'
36
import {
47
INPUT_DISPATCH_MAX_HOLD_MS,
58
beginInputDispatch
@@ -540,3 +543,51 @@ describe('ScreencastRecorderBase — buffer cap / decimation', () => {
540543
await r.stop()
541544
})
542545
})
546+
547+
describe('ScreencastRecorderBase: lifecycle logging', () => {
548+
function makeLogger() {
549+
const lines: Array<[string, string]> = []
550+
const logger: ScreencastLogger = {
551+
info: (m) => lines.push(['info', m]),
552+
warn: (m) => lines.push(['warn', m])
553+
}
554+
return { logger, lines }
555+
}
556+
557+
it('reports polling start and stop through the adapter logger', async () => {
558+
const { logger, lines } = makeLogger()
559+
class Logged extends TestRecorder {
560+
protected override readonly logger = logger
561+
}
562+
const r = new Logged({ pollIntervalMs: 50 })
563+
await r.start({ name: 'driver' })
564+
await r.stop()
565+
expect(lines).toEqual([
566+
['info', '✓ Screencast recording started (polling mode, 50 ms interval)'],
567+
['info', '✓ Screencast stopped — 1 frame(s) collected']
568+
])
569+
})
570+
571+
it('warns with the cause when the first shot cannot be taken', async () => {
572+
const { logger, lines } = makeLogger()
573+
class Logged extends TestRecorder {
574+
protected override readonly logger = logger
575+
}
576+
const r = new Logged({ pollIntervalMs: 50 })
577+
r.shouldFailFirst = true
578+
await r.start({ name: 'driver' })
579+
expect(lines).toEqual([
580+
[
581+
'warn',
582+
'Screencast unavailable (first screenshot returned null). Recording skipped.'
583+
]
584+
])
585+
})
586+
587+
it('stays silent without a logger', async () => {
588+
const r = new TestRecorder({ pollIntervalMs: 50 })
589+
r.shouldFailFirst = true
590+
await expect(r.start({ name: 'driver' })).resolves.toBeUndefined()
591+
expect(r.isRecording).toBe(false)
592+
})
593+
})

‎packages/nightwatch-devtools/src/bidi.ts‎

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import {
33
type BidiHandlerSinks,
44
attachBidiHandlers as attachBidiHandlersCore
55
} from '@wdio/devtools-core'
6-
import type { SessionCapturer } from './session.js'
76

87
const log = logger('@wdio/nightwatch-devtools:bidi')
98

9+
export { buildBidiSinks } from '@wdio/devtools-core'
10+
1011
/**
1112
* Nightwatch wrapper around the core BiDi attach helper. Nightwatch ships
1213
* selenium-webdriver under the hood (via chromedriver), so the same
@@ -25,34 +26,3 @@ export async function attachBidiHandlers(
2526
log[level](message)
2627
)
2728
}
28-
29-
/**
30-
* Build sinks that route BiDi events into the SessionCapturer's local arrays
31-
* and broadcast them upstream. Mirrors selenium-devtools' `buildBidiSinks` —
32-
* separate per-adapter because the SessionCapturer concrete types differ.
33-
*/
34-
export function buildBidiSinks(capturer: SessionCapturer): BidiHandlerSinks {
35-
return {
36-
pushConsoleLog: (entry) => {
37-
capturer.consoleLogs.push(entry)
38-
capturer.sendUpstream('consoleLogs', [entry])
39-
},
40-
pushNetworkRequest: (entry) => {
41-
capturer.bidiNetworkObserved = true
42-
capturer.networkRequests.push(entry)
43-
capturer.sendUpstream('networkRequests', [entry])
44-
},
45-
replaceNetworkRequest: (id, entry) => {
46-
capturer.bidiNetworkObserved = true
47-
const idx = capturer.networkRequests.findIndex(
48-
(r: { id?: string }) => r.id === id
49-
)
50-
if (idx !== -1) {
51-
capturer.networkRequests[idx] = entry
52-
} else {
53-
capturer.networkRequests.push(entry)
54-
}
55-
capturer.sendUpstream('networkRequests', [entry])
56-
}
57-
}
58-
}

‎packages/nightwatch-devtools/src/cucumber-lifecycle.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface CucumberLifecycleCtx extends TestSliceCtx {
6464
setCurrentTest(t: unknown): void
6565
recordAttempt(uid: string, specFile?: string): number
6666
recordOutcome(uid: string, state: TestStats['state']): void
67+
/** Produces (no attach) this test's screenshot and video, core-gated to trace mode and `test` granularity. */
6768
emitTestArtifacts(uid: string | undefined, failed: boolean): Promise<void>
6869
}
6970

0 commit comments

Comments
 (0)