Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions packages/core/src/bidi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,50 @@ import {
import { errorMessage } from './error.js'
import { getRequestType } from './net.js'

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

/** The capturer state BiDi events land in; `bidiNetworkObserved` is the evidence latch that gates the page's own rows. */
export interface BidiSinkTarget {
consoleLogs: ConsoleLog[]
networkRequests: NetworkRequest[]
sendUpstream(event: string, data: unknown): void
bidiNetworkObserved: boolean
}

/** Route BiDi events into the target's arrays and upstream; `onConsoleEntry` runs before each console entry is kept. */
export function buildBidiSinks(
target: BidiSinkTarget,
onConsoleEntry?: () => void
): BidiHandlerSinks {
return {
pushConsoleLog: (entry) => {
onConsoleEntry?.()
target.consoleLogs.push(entry)
target.sendUpstream('consoleLogs', [entry])
},
pushNetworkRequest: (entry) => {
target.bidiNetworkObserved = true
target.networkRequests.push(entry)
target.sendUpstream('networkRequests', [entry])
},
replaceNetworkRequest: (id, entry) => {
target.bidiNetworkObserved = true
const idx = target.networkRequests.findIndex((r) => r.id === id)
if (idx !== -1) {
target.networkRequests[idx] = entry
} else {
target.networkRequests.push(entry)
}
target.sendUpstream('networkRequests', [entry])
}
}
}

/**
* Resolve a `selenium-webdriver/<subpath>` module from the user's install
* (preferred) or the package's local install (fallback). Returns `null` if
Expand Down
31 changes: 21 additions & 10 deletions packages/core/src/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared'
import { SCREENCAST_DEFAULTS } from '@wdio/devtools-shared'
import { isInputDispatchInFlight } from './input-dispatch.js'
import { withTimeout } from './with-timeout.js'
import { errorMessage } from './error.js'

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

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

/** Where a recorder reports its lifecycle; each adapter passes its own logger. */
export interface ScreencastLogger {
info(message: string): void
warn(message: string): void
}

/**
* Shared screencast scaffolding consumed by every adapter (service, selenium,
* nightwatch). Owns the frame buffer, public API (start/stop/setStartMarker,
Expand Down Expand Up @@ -235,22 +242,26 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {

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

/**
* Hook fired when the polling loop starts. Default: no-op. Subclasses
* (adapters with their own logger) override to surface visibility.
*/
protected onPollingStarted(_intervalMs: number): void {
// no-op
/** The adapter's logger for the hooks below; none keeps them silent. */
protected readonly logger: ScreencastLogger | undefined = undefined

/** Hook fired when the polling loop starts. */
protected onPollingStarted(intervalMs: number): void {
this.logger?.info(
`✓ Screencast recording started (polling mode, ${intervalMs} ms interval)`
)
}

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

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

// ─── Polling implementation ─────────────────────────────────────────────
Expand Down
71 changes: 71 additions & 0 deletions packages/core/tests/bidi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
import {
arrayHeadersToObject,
attachBidiHandlers,
buildBidiSinks,
type BidiSinkTarget,
loadSeleniumSubmodule,
handleBidiConsoleEntry,
handleBidiJsException,
Expand Down Expand Up @@ -670,3 +672,72 @@ describe('attachNetworkInspector with an inspector that has a fetchError hook',
expect(replacements.at(-1)?.entry).toMatchObject({ id: 'open', status: 0 })
})
})

describe('buildBidiSinks', () => {
function makeTarget() {
const sent: Array<[string, unknown]> = []
const target: BidiSinkTarget = {
consoleLogs: [],
networkRequests: [],
bidiNetworkObserved: false,
sendUpstream: (event, data) => sent.push([event, data])
}
return { target, sent }
}
const request = (id: string, url = 'https://x/'): NetworkRequest => ({
id,
url,
method: 'GET',
type: 'document',
timestamp: 1,
startTime: 0
})

it('keeps and forwards a console entry, calling the hook first', () => {
const { target, sent } = makeTarget()
const seen: number[] = []
const sinks = buildBidiSinks(target, () =>
seen.push(target.consoleLogs.length)
)
const entry = { type: 'log' as const, args: ['hi'], timestamp: 1 }
sinks.pushConsoleLog(entry)
expect(seen).toEqual([0])
expect(target.consoleLogs).toEqual([entry])
expect(sent).toEqual([['consoleLogs', [entry]]])
expect(target.bidiNetworkObserved).toBe(false)
})

it('accepts a console entry without a hook', () => {
const { target } = makeTarget()
buildBidiSinks(target).pushConsoleLog({
type: 'log',
args: ['hi'],
timestamp: 1
})
expect(target.consoleLogs).toHaveLength(1)
})

it('latches network evidence on a pushed request', () => {
const { target, sent } = makeTarget()
buildBidiSinks(target).pushNetworkRequest(request('a'))
expect(target.bidiNetworkObserved).toBe(true)
expect(target.networkRequests.map((r) => r.id)).toEqual(['a'])
expect(sent).toEqual([['networkRequests', [request('a')]]])
})

it('replaces a known request in place and appends an unknown one', () => {
const { target } = makeTarget()
const sinks = buildBidiSinks(target)
sinks.pushNetworkRequest(request('a'))
sinks.pushNetworkRequest(request('b'))
target.bidiNetworkObserved = false
sinks.replaceNetworkRequest('a', request('a', 'https://done/'))
sinks.replaceNetworkRequest('c', request('c'))
expect(target.bidiNetworkObserved).toBe(true)
expect(target.networkRequests.map((r) => [r.id, r.url])).toEqual([
['a', 'https://done/'],
['b', 'https://x/'],
['c', 'https://x/']
])
})
})
53 changes: 52 additions & 1 deletion packages/core/tests/screencast.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ScreencastRecorderBase } from '../src/screencast.js'
import {
ScreencastRecorderBase,
type ScreencastLogger
} from '../src/screencast.js'
import {
INPUT_DISPATCH_MAX_HOLD_MS,
beginInputDispatch
Expand Down Expand Up @@ -540,3 +543,51 @@ describe('ScreencastRecorderBase — buffer cap / decimation', () => {
await r.stop()
})
})

describe('ScreencastRecorderBase: lifecycle logging', () => {
function makeLogger() {
const lines: Array<[string, string]> = []
const logger: ScreencastLogger = {
info: (m) => lines.push(['info', m]),
warn: (m) => lines.push(['warn', m])
}
return { logger, lines }
}

it('reports polling start and stop through the adapter logger', async () => {
const { logger, lines } = makeLogger()
class Logged extends TestRecorder {
protected override readonly logger = logger
}
const r = new Logged({ pollIntervalMs: 50 })
await r.start({ name: 'driver' })
await r.stop()
expect(lines).toEqual([
['info', '✓ Screencast recording started (polling mode, 50 ms interval)'],
['info', '✓ Screencast stopped — 1 frame(s) collected']
])
})

it('warns with the cause when the first shot cannot be taken', async () => {
const { logger, lines } = makeLogger()
class Logged extends TestRecorder {
protected override readonly logger = logger
}
const r = new Logged({ pollIntervalMs: 50 })
r.shouldFailFirst = true
await r.start({ name: 'driver' })
expect(lines).toEqual([
[
'warn',
'Screencast unavailable (first screenshot returned null). Recording skipped.'
]
])
})

it('stays silent without a logger', async () => {
const r = new TestRecorder({ pollIntervalMs: 50 })
r.shouldFailFirst = true
await expect(r.start({ name: 'driver' })).resolves.toBeUndefined()
expect(r.isRecording).toBe(false)
})
})
34 changes: 2 additions & 32 deletions packages/nightwatch-devtools/src/bidi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {
type BidiHandlerSinks,
attachBidiHandlers as attachBidiHandlersCore
} from '@wdio/devtools-core'
import type { SessionCapturer } from './session.js'

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

export { buildBidiSinks } from '@wdio/devtools-core'

/**
* Nightwatch wrapper around the core BiDi attach helper. Nightwatch ships
* selenium-webdriver under the hood (via chromedriver), so the same
Expand All @@ -25,34 +26,3 @@ export async function attachBidiHandlers(
log[level](message)
)
}

/**
* Build sinks that route BiDi events into the SessionCapturer's local arrays
* and broadcast them upstream. Mirrors selenium-devtools' `buildBidiSinks` —
* separate per-adapter because the SessionCapturer concrete types differ.
*/
export function buildBidiSinks(capturer: SessionCapturer): BidiHandlerSinks {
return {
pushConsoleLog: (entry) => {
capturer.consoleLogs.push(entry)
capturer.sendUpstream('consoleLogs', [entry])
},
pushNetworkRequest: (entry) => {
capturer.bidiNetworkObserved = true
capturer.networkRequests.push(entry)
capturer.sendUpstream('networkRequests', [entry])
},
replaceNetworkRequest: (id, entry) => {
capturer.bidiNetworkObserved = true
const idx = capturer.networkRequests.findIndex(
(r: { id?: string }) => r.id === id
)
if (idx !== -1) {
capturer.networkRequests[idx] = entry
} else {
capturer.networkRequests.push(entry)
}
capturer.sendUpstream('networkRequests', [entry])
}
}
}
1 change: 1 addition & 0 deletions packages/nightwatch-devtools/src/cucumber-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface CucumberLifecycleCtx extends TestSliceCtx {
setCurrentTest(t: unknown): void
recordAttempt(uid: string, specFile?: string): number
recordOutcome(uid: string, state: TestStats['state']): void
/** Produces (no attach) this test's screenshot and video, core-gated to trace mode and `test` granularity. */
emitTestArtifacts(uid: string | undefined, failed: boolean): Promise<void>
}

Expand Down
Loading
Loading