diff --git a/packages/core/src/bidi.ts b/packages/core/src/bidi.ts index b3f84dea..235676de 100644 --- a/packages/core/src/bidi.ts +++ b/packages/core/src/bidi.ts @@ -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/` module from the user's install * (preferred) or the package's local install (fallback). Returns `null` if diff --git a/packages/core/src/screencast.ts b/packages/core/src/screencast.ts index fdf08a7a..e43d2631 100644 --- a/packages/core/src/screencast.ts +++ b/packages/core/src/screencast.ts @@ -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 @@ -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, @@ -235,22 +242,26 @@ export abstract class ScreencastRecorderBase { // ─── 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 ───────────────────────────────────────────── diff --git a/packages/core/tests/bidi.test.ts b/packages/core/tests/bidi.test.ts index 3716c8ae..a853bb92 100644 --- a/packages/core/tests/bidi.test.ts +++ b/packages/core/tests/bidi.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest' import { arrayHeadersToObject, attachBidiHandlers, + buildBidiSinks, + type BidiSinkTarget, loadSeleniumSubmodule, handleBidiConsoleEntry, handleBidiJsException, @@ -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/'] + ]) + }) +}) diff --git a/packages/core/tests/screencast.test.ts b/packages/core/tests/screencast.test.ts index 3b25cb01..9fcfcd62 100644 --- a/packages/core/tests/screencast.test.ts +++ b/packages/core/tests/screencast.test.ts @@ -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 @@ -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) + }) +}) diff --git a/packages/nightwatch-devtools/src/bidi.ts b/packages/nightwatch-devtools/src/bidi.ts index 687d2115..f45fef1a 100644 --- a/packages/nightwatch-devtools/src/bidi.ts +++ b/packages/nightwatch-devtools/src/bidi.ts @@ -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 @@ -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]) - } - } -} diff --git a/packages/nightwatch-devtools/src/cucumber-lifecycle.ts b/packages/nightwatch-devtools/src/cucumber-lifecycle.ts index 7a6491d6..2ae03032 100644 --- a/packages/nightwatch-devtools/src/cucumber-lifecycle.ts +++ b/packages/nightwatch-devtools/src/cucumber-lifecycle.ts @@ -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 } diff --git a/packages/nightwatch-devtools/src/plugin-internals.ts b/packages/nightwatch-devtools/src/plugin-internals.ts index af3dc7b9..4018795d 100644 --- a/packages/nightwatch-devtools/src/plugin-internals.ts +++ b/packages/nightwatch-devtools/src/plugin-internals.ts @@ -7,103 +7,27 @@ * four) while still letting each lifecycle module narrow its dependencies. */ -import type { SpecRange, TraceArtifact } from '@wdio/devtools-core' +import type { BrowserProxy } from './helpers/browserProxy.js' import type { SessionCapturer } from './session.js' import type { TestReporter } from './reporter.js' -import type { ScreencastRecorder } from './screencast.js' import type { TestManager } from './helpers/testManager.js' import type { SuiteManager } from './helpers/suiteManager.js' -import type { BrowserProxy } from './helpers/browserProxy.js' -import type { - DevToolsMode, - NightwatchBrowser, - ScreencastOptions, - SuiteStats, - TestRunnerId, - TestStats, - TraceGranularity -} from './types.js' - -export interface PluginInternals { - // Config + options - options: { hostname: string; port: number; mode?: DevToolsMode } - readonly hostname: string - readonly port: number - readonly mode: DevToolsMode - readonly screencastOptions: ScreencastOptions - readonly bidiEnabled: boolean - readonly captureAssertions: boolean - /** Derived from the cucumber flag, so one value answers both the metadata - * payload and the capture's locator dialect. */ - readonly runner: TestRunnerId - - // Runtime instances (mutable — bringup/session-change replaces them) +import type { SessionInitCtx } from './session-init.js' +import type { TestLifecycleCtx } from './test-lifecycle.js' +import type { CucumberLifecycleCtx } from './cucumber-lifecycle.js' +import type { RunLifecycleCtx } from './run-lifecycle.js' + +export interface PluginInternals + extends + SessionInitCtx, + TestLifecycleCtx, + CucumberLifecycleCtx, + RunLifecycleCtx { + // Redeclared because the bases disagree on these, which `extends` rejects. sessionCapturer: SessionCapturer testReporter: TestReporter testManager: TestManager suiteManager: SuiteManager browserProxy: BrowserProxy - isScriptInjected: boolean - devtoolsBrowser: WebdriverIO.Browser | undefined - userDataDir: string | undefined - - // Run state - passCount: number - failCount: number - skipCount: number - - // Session state - lastSessionId: string | null - /** Session the BiDi attach + collector preload were last armed for; a command - * seen under any other session is a mid-run `browser.end()`. */ - armedSessionId: string | undefined srcFolders: string[] - screencastRecorder: ScreencastRecorder | undefined - screencastSessionId: string | undefined - screencastRotation: Promise | undefined - - /** Absolute path to the resolved Nightwatch config file, if known. Used as - * a fallback directory for screencast video output. */ - configPath: string | undefined - - // Current execution (set by lifecycle, read across modules) - getCurrentTest(): unknown - getCurrentScenarioSuite(): SuiteStats | null - getCurrentStep(): unknown - setCurrentTest(t: unknown): void - setCurrentScenarioSuite(s: SuiteStats | null): void - setCurrentStep(s: unknown): void - - // Plugin-side delegates - clearExecutionData(): void - buildMetadataOptions(): unknown - ensureSessionInitialized(b: NightwatchBrowser): Promise - finalizeCurrentScreencast(): Promise - wrapBrowserOnce(b: NightwatchBrowser): void - incrementCount(state: TestStats['state']): void - testIcon(state: TestStats['state']): string - setCucumberRunner(v: boolean): void - getRerunLabel(): string | undefined - - /** Records a test/scenario start under its retry-stable uid; `specFile` - * (when known) enables spec-scoped retention. Returns the 0-based attempt - * number (0 first run, +1 per rerun). */ - recordAttempt(uid: string, specFile?: string): number - /** Stamps the resolved terminal state onto uid's latest attempt slot so the - * retry-aware retention policies see real per-attempt outcomes. */ - recordOutcome(uid: string, state: TestStats['state']): void - /** Latest attempt recorded for `uid`, or undefined if it never started. */ - attemptFor(uid: string): number | undefined - - // Per-test trace slicing (`test` granularity). Boundary state is shared with - // the finalizer; flushTraceRange writes one slice via the plugin's context. - readonly traceMode: boolean - readonly traceGranularity: TraceGranularity - readonly specRanges: SpecRange[] - readonly flushedSpecs: Set - flushTraceRange(range: SpecRange): Promise - - /** Produce (no attach) this test's per-test screenshot + video artifacts. - * Core-gated to trace mode + `test` granularity. */ - emitTestArtifacts(uid: string | undefined, failed: boolean): Promise } diff --git a/packages/nightwatch-devtools/src/screencast.ts b/packages/nightwatch-devtools/src/screencast.ts index 26c7a2f5..37c55500 100644 --- a/packages/nightwatch-devtools/src/screencast.ts +++ b/packages/nightwatch-devtools/src/screencast.ts @@ -1,5 +1,8 @@ import logger from '@wdio/logger' -import { ScreencastRecorderBase, errorMessage } from '@wdio/devtools-core' +import { + ScreencastRecorderBase, + type ScreencastLogger +} from '@wdio/devtools-core' import type { ScreencastOptions } from '@wdio/devtools-shared' import type { SessionCapturer } from './session.js' import type { NightwatchBrowser } from './types.js' @@ -24,21 +27,7 @@ export class ScreencastRecorder extends ScreencastRecorderBase { const browser = this.driver diff --git a/packages/nightwatch-devtools/src/session-init.ts b/packages/nightwatch-devtools/src/session-init.ts index af5094e8..719413b1 100644 --- a/packages/nightwatch-devtools/src/session-init.ts +++ b/packages/nightwatch-devtools/src/session-init.ts @@ -59,6 +59,7 @@ export interface SessionInitCtx { readonly bidiEnabled: boolean readonly mode: DevToolsMode readonly captureAssertions: boolean + /** Derived from the cucumber flag, so one value answers both the metadata payload and the locator dialect. */ readonly runner: TestRunnerId sessionCapturer: SessionCapturer @@ -77,12 +78,15 @@ export interface SessionInitCtx { screencastSessionId: string | undefined /** In-flight recorder rotation — see screencast-session.ts. */ screencastRotation: Promise | undefined + /** Resolved Nightwatch config file, the fallback directory for screencast video output. */ configPath: string | undefined getCurrentTest(): unknown getCurrentScenarioSuite(): SuiteStats | null buildMetadataOptions(): unknown + /** Latest attempt recorded for `uid`, or undefined if it never started. */ attemptFor(uid: string): number | undefined + /** Stamps the terminal state onto uid's latest attempt, for the retry-aware retention policies. */ recordOutcome(uid: string, state: TestStats['state']): void /** Drains the live recorder's frames into the run's filmstrip buffer, then * stops and clears it. */ diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index 9d2913d1..01689524 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -126,26 +126,14 @@ export class SessionCapturer extends SessionCapturerBase { error: Error | undefined, meta: CommandCaptureMeta = {} ): Promise { - // Serialize error properly (Error objects don't JSON.stringify well) - const serializedError = serializeError(error) - - const commandId = this.commandCounter++ // A public `id`, so a late replace of this row cannot land on another row stamped in the same millisecond. - const commandLogEntry: CommandLog & { _id?: number } = { - _id: commandId, - id: commandId, + const commandLogEntry = this.#commandEntry( command, args, result, - error: serializedError, - timestamp: meta.timestamp || Date.now(), - startTime: meta.startTime, - sequence: meta.sequence, - callSource: meta.callSource, - testUid: meta.testUid, - selector: meta.selector, - parentId: meta.parentId - } + error, + meta + ) this.commandsLog.push(commandLogEntry) @@ -283,16 +271,36 @@ export class SessionCapturer extends SessionCapturerBase { // Allow the slot to be re-used by a new entry this.sentCommandIds.delete(oldId) - const serializedError = serializeError(error) - const commandId = this.commandCounter++ // The retry keeps the row's public `id`, so the dashboard updates the row it already shows. - const entry: CommandLog & { _id?: number } = { + const entry = this.#commandEntry( + command, + args, + result, + error, + meta, + previous?.id + ) + this.commandsLog.push(entry) + return { entry, oldTimestamp } + } + + #commandEntry( + command: string, + args: unknown[], + result: unknown, + error: Error | undefined, + meta: CommandCaptureMeta, + publicId?: number + ): CommandLog & { _id?: number } { + const commandId = this.commandCounter++ + return { _id: commandId, - id: previous?.id ?? commandId, + id: publicId ?? commandId, command, args, result, - error: serializedError, + // Error objects don't JSON.stringify well. + error: serializeError(error), timestamp: meta.timestamp || Date.now(), startTime: meta.startTime, sequence: meta.sequence, @@ -301,8 +309,6 @@ export class SessionCapturer extends SessionCapturerBase { selector: meta.selector, parentId: meta.parentId } - this.commandsLog.push(entry) - return { entry, oldTimestamp } } /** Rows {@link openCommand} sent that have not ended yet. */ diff --git a/packages/nightwatch-devtools/src/test-lifecycle.ts b/packages/nightwatch-devtools/src/test-lifecycle.ts index 4c181704..eafd673b 100644 --- a/packages/nightwatch-devtools/src/test-lifecycle.ts +++ b/packages/nightwatch-devtools/src/test-lifecycle.ts @@ -41,6 +41,7 @@ export interface TestLifecycleCtx extends TestSliceCtx { incrementCount(state: TestStats['state']): void testIcon(state: TestStats['state']): string setCurrentTest(t: unknown): void + /** Records a start under its retry-stable uid and returns the 0-based attempt; `specFile` enables spec-scoped retention. */ recordAttempt(uid: string, specFile?: string): number } diff --git a/packages/selenium-devtools/src/bidi.ts b/packages/selenium-devtools/src/bidi.ts index fc823415..53f448ce 100644 --- a/packages/selenium-devtools/src/bidi.ts +++ b/packages/selenium-devtools/src/bidi.ts @@ -2,6 +2,7 @@ import logger from '@wdio/logger' import { type BidiHandlerSinks, attachBidiHandlers as attachBidiHandlersCore, + buildBidiSinks as buildBidiSinksCore, errorMessage } from '@wdio/devtools-core' import type { SessionCapturer } from './session.js' @@ -114,30 +115,7 @@ export async function attachBidiHandlers( } export function buildBidiSinks(capturer: SessionCapturer): BidiHandlerSinks { - return { - pushConsoleLog: (entry) => { - capturer.bidiConsoleObserved = true - 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: NetworkRequestWithId) => r.id === id - ) - if (idx !== -1) { - capturer.networkRequests[idx] = entry - } else { - capturer.networkRequests.push(entry) - } - capturer.sendUpstream('networkRequests', [entry]) - } - } + return buildBidiSinksCore(capturer, () => { + capturer.bidiConsoleObserved = true + }) } - -type NetworkRequestWithId = { id?: string } diff --git a/packages/selenium-devtools/src/plugin-internals.ts b/packages/selenium-devtools/src/plugin-internals.ts index 38dad35a..8544e2a6 100644 --- a/packages/selenium-devtools/src/plugin-internals.ts +++ b/packages/selenium-devtools/src/plugin-internals.ts @@ -7,82 +7,20 @@ * narrow its dependencies. */ -import type { SessionCapturer } from './session.js' +import type { SessionLifecycleCtx } from './session-lifecycle.js' +import type { TestManagementCtx } from './test-management.js' +import type { OnCommandCtx } from './helpers/commandPostActions.js' import type { TestReporter } from './reporter.js' -import type { SuiteManager } from './helpers/suiteManager.js' +import type { SessionCapturer } from './session.js' import type { TestManager } from './helpers/testManager.js' -import type { ScreencastRecorder } from './screencast.js' -import type { - ActionSnapshot, - DetectedRunner, - DevToolsMode, - ScreencastFrame, - ScreencastOptions, - SeleniumDriverLike, - TraceFormat, - TraceGranularity -} from './types.js' -import type { RetryTracker } from '@wdio/devtools-core' -import type { PendingTestAction, PendingScenario } from './test-management.js' -import type { SpecRange, TraceArtifact } from '@wdio/devtools-core' +import type { SeleniumDriverLike } from './types.js' -export interface PluginInternals { - // Config - readonly options: { - hostname: string - port: number - openUi: boolean - captureScreenshots: boolean - rerunCommand?: string - mode?: DevToolsMode - traceFormat?: TraceFormat - traceGranularity?: TraceGranularity - } - readonly screencastOptions: ScreencastOptions - /** The JS test runner `detectRunner` found (mocha/jest/cucumber). Distinct - * from `Metadata.runner`, which names this adapter for every one of them. */ - readonly detectedRunner: DetectedRunner - readonly rerunTemplate: string | undefined - readonly launchCommand: string | undefined - readonly isReuse: boolean - readonly finalized: boolean - readonly retryTracker: RetryTracker - /** Whether the `build()` that created this driver asked for appium-interceptor's proxy. */ - interceptRequestedFor(driver: SeleniumDriverLike): boolean - - // Mutable runtime instances +export interface PluginInternals + extends SessionLifecycleCtx, TestManagementCtx, OnCommandCtx { + // Redeclared because the bases disagree on these, which `extends` rejects. + readonly options: SessionLifecycleCtx['options'] driver: SeleniumDriverLike | undefined sessionCapturer: SessionCapturer | undefined testReporter: TestReporter | undefined - suiteManager: SuiteManager | undefined testManager: TestManager | undefined - screencast: ScreencastRecorder | undefined - sessionId: string | undefined - testFilePath: string | undefined - keepAliveTimer: ReturnType | undefined - - // Test management buffers - pendingTestActions: PendingTestAction[] - pendingScenario: PendingScenario | null - - // trace-mode snapshot accumulators (mode === 'trace' only) - readonly actionSnapshots: ActionSnapshot[] - readonly snapshotCaptures: Promise[] - - // Per-spec trace tracking (populated at spec file boundaries). - readonly specRanges: SpecRange[] - readonly flushedSpecs: Set - // In-flight per-test eager flushes (test granularity), awaited at finalize. - readonly traceFlushes: Promise[] - // Every trace/video artifact seen this run, for the end-of-run manifest. - readonly artifacts: TraceArtifact[] - // Dense filmstrip frames accumulated across drivers (filmstrip option only). - readonly filmstripFrames: ScreencastFrame[] - - // Plugin-side delegates - setFinalized(v: boolean): void - ensureBackendStarted(): Promise - flushPendingTestActions(): void - resetRetryTracker(): void - clearKeepAlive(): void } diff --git a/packages/selenium-devtools/src/runnerHooks/cucumber.ts b/packages/selenium-devtools/src/runnerHooks/cucumber.ts index ba7e56c0..b6a33800 100644 --- a/packages/selenium-devtools/src/runnerHooks/cucumber.ts +++ b/packages/selenium-devtools/src/runnerHooks/cucumber.ts @@ -7,6 +7,7 @@ import type { TestStatus } from '@wdio/devtools-shared' import type { RunnerHookCallbacks } from '../types.js' +import { reportRunComplete, type RunCounters } from './run-summary.js' const log = logger('@wdio/selenium-devtools:runnerHooks:cucumber') @@ -31,14 +32,6 @@ interface GherkinIndex { scenarioLineById: Map } -interface RunCounters { - runStartTs: number - started: number - passed: number - failed: number - pending: number -} - function loadCucumber(): CucumberModule | null { try { return createRequire(`${process.cwd()}/`)('@cucumber/cucumber') @@ -250,21 +243,7 @@ function registerRunLifecycleHooks( counters.runStartTs = Date.now() log.info('🧪 Test run starting') }) - AfterAll(() => { - const durationMs = Date.now() - counters.runStartTs - const duration = (durationMs / 1000).toFixed(2) - log.info( - `🧪 Test run complete: ${counters.passed} passed, ${counters.failed} failed` + - (counters.pending ? `, ${counters.pending} pending` : '') + - ` (${duration}s, ${counters.started} total)` - ) - callbacks.onTestRunComplete?.({ - passed: counters.passed, - failed: counters.failed, - pending: counters.pending, - durationMs - }) - }) + AfterAll(() => reportRunComplete(counters, callbacks, log)) } function handleScenarioStart( diff --git a/packages/selenium-devtools/src/runnerHooks/jest.ts b/packages/selenium-devtools/src/runnerHooks/jest.ts index 8dabd583..5c8b2fc5 100644 --- a/packages/selenium-devtools/src/runnerHooks/jest.ts +++ b/packages/selenium-devtools/src/runnerHooks/jest.ts @@ -2,6 +2,7 @@ import logger from '@wdio/logger' import { errorMessage } from '@wdio/devtools-core' import { findTestLineInFile } from '../helpers/utils.js' import type { RunnerHookCallbacks } from '../types.js' +import { reportRunComplete } from './run-summary.js' const log = logger('@wdio/selenium-devtools:runnerHooks:jest') @@ -161,20 +162,19 @@ function registerJestRunLifecycle( state.runStartTs = Date.now() log.info('🧪 Test run starting') }) - g.afterAll(() => { - const durationMs = Date.now() - state.runStartTs - const duration = (durationMs / 1000).toFixed(2) - log.info( - `🧪 Test run complete: ${state.testsPassed} passed, ${state.testsFailed} failed ` + - `(${duration}s, ${state.testsStarted} total)` + g.afterAll(() => + reportRunComplete( + { + runStartTs: state.runStartTs, + started: state.testsStarted, + passed: state.testsPassed, + failed: state.testsFailed, + pending: 0 + }, + callbacks, + log ) - callbacks.onTestRunComplete?.({ - passed: state.testsPassed, - failed: state.testsFailed, - pending: 0, - durationMs - }) - }) + ) } function registerJestBeforeEach( diff --git a/packages/selenium-devtools/src/runnerHooks/mocha.ts b/packages/selenium-devtools/src/runnerHooks/mocha.ts index b9b3988c..974711ac 100644 --- a/packages/selenium-devtools/src/runnerHooks/mocha.ts +++ b/packages/selenium-devtools/src/runnerHooks/mocha.ts @@ -1,6 +1,7 @@ import logger from '@wdio/logger' import { errorMessage } from '@wdio/devtools-core' import { findTestLineInFile } from '../helpers/utils.js' +import { reportRunComplete, type RunCounters } from './run-summary.js' import type { MochaRootHooks, MochaTestCtx, @@ -18,18 +19,10 @@ type MochaGlobals = { after?: (fn: () => void) => void } -interface MochaCounters { - runStartTs: number - started: number - passed: number - failed: number - pending: number -} - export function buildMochaRootHooks( callbacks: RunnerHookCallbacks ): MochaRootHooks { - const counters: MochaCounters = { + const counters: RunCounters = { runStartTs: 0, started: 0, passed: 0, @@ -48,24 +41,10 @@ export function buildMochaRootHooks( } function makeMochaRunEnd( - counters: MochaCounters, + counters: RunCounters, callbacks: RunnerHookCallbacks ): () => void { - return () => { - const durationMs = Date.now() - counters.runStartTs - const duration = (durationMs / 1000).toFixed(2) - log.info( - `🧪 Test run complete: ${counters.passed} passed, ${counters.failed} failed` + - (counters.pending ? `, ${counters.pending} pending` : '') + - ` (${duration}s, ${counters.started} total)` - ) - callbacks.onTestRunComplete?.({ - passed: counters.passed, - failed: counters.failed, - pending: counters.pending, - durationMs - }) - } + return () => reportRunComplete(counters, callbacks, log) } function resolveCallSource( @@ -81,7 +60,7 @@ function resolveCallSource( } function makeMochaBeforeEach( - counters: MochaCounters, + counters: RunCounters, callbacks: RunnerHookCallbacks ): (this: { currentTest?: MochaTestCtx }) => void { return function (this: { currentTest?: MochaTestCtx }) { @@ -125,7 +104,7 @@ function resolveMochaState( } function makeMochaAfterEach( - counters: MochaCounters, + counters: RunCounters, callbacks: RunnerHookCallbacks ): (this: { currentTest?: MochaTestCtx }) => Promise { // Async so mocha awaits the per-test artifact emit while the test is still diff --git a/packages/selenium-devtools/src/runnerHooks/run-summary.ts b/packages/selenium-devtools/src/runnerHooks/run-summary.ts new file mode 100644 index 00000000..5719e533 --- /dev/null +++ b/packages/selenium-devtools/src/runnerHooks/run-summary.ts @@ -0,0 +1,29 @@ +import type { RunnerHookCallbacks } from '../types.js' + +export interface RunCounters { + runStartTs: number + started: number + passed: number + failed: number + pending: number +} + +export function reportRunComplete( + counters: RunCounters, + callbacks: RunnerHookCallbacks, + log: { info(message: string): void } +): void { + const durationMs = Date.now() - counters.runStartTs + const duration = (durationMs / 1000).toFixed(2) + log.info( + `🧪 Test run complete: ${counters.passed} passed, ${counters.failed} failed` + + (counters.pending ? `, ${counters.pending} pending` : '') + + ` (${duration}s, ${counters.started} total)` + ) + callbacks.onTestRunComplete?.({ + passed: counters.passed, + failed: counters.failed, + pending: counters.pending, + durationMs + }) +} diff --git a/packages/selenium-devtools/src/screencast.ts b/packages/selenium-devtools/src/screencast.ts index e80e7aea..30f1a92a 100644 --- a/packages/selenium-devtools/src/screencast.ts +++ b/packages/selenium-devtools/src/screencast.ts @@ -1,6 +1,7 @@ import logger from '@wdio/logger' import { ScreencastRecorderBase, + type ScreencastLogger, SCREENCAST_HANDSHAKE_TIMEOUT_MS, errorMessage, withTimeout @@ -45,21 +46,7 @@ export class ScreencastRecorder extends ScreencastRecorderBase void) | undefined - protected override onPollingStarted(intervalMs: number): void { - log.info( - `✓ Screencast recording started (polling mode, ${intervalMs} ms interval)` - ) - } - - protected override onPollingStopped(frameCount: number): void { - log.info(`✓ Screencast stopped — ${frameCount} frame(s) collected`) - } - - protected override onUnavailable(err: unknown): void { - log.warn( - `Screencast unavailable (${errorMessage(err)}). Recording skipped.` - ) - } + protected override readonly logger: ScreencastLogger = log protected override async takeScreenshot(): Promise { const driver = this.driver diff --git a/packages/selenium-devtools/tests/run-summary.test.ts b/packages/selenium-devtools/tests/run-summary.test.ts new file mode 100644 index 00000000..9099d5ff --- /dev/null +++ b/packages/selenium-devtools/tests/run-summary.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { reportRunComplete } from '../src/runnerHooks/run-summary.js' +import type { RunnerHookCallbacks } from '../src/types.js' + +function fakeLog() { + const info = vi.fn() + return { log: { info }, info } +} + +function callbacks( + onTestRunComplete?: RunnerHookCallbacks['onTestRunComplete'] +): RunnerHookCallbacks { + return { + onTestStart: vi.fn(), + onTestEnd: vi.fn(async () => {}), + onTestRunComplete + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('reportRunComplete', () => { + it('logs the totals and hands the summary to the plugin', () => { + vi.useFakeTimers() + vi.setSystemTime(3500) + const { log, info } = fakeLog() + const onTestRunComplete = vi.fn() + reportRunComplete( + { runStartTs: 1000, started: 4, passed: 2, failed: 1, pending: 1 }, + callbacks(onTestRunComplete), + log + ) + expect(info).toHaveBeenCalledWith( + '🧪 Test run complete: 2 passed, 1 failed, 1 pending (2.50s, 4 total)' + ) + expect(onTestRunComplete).toHaveBeenCalledWith({ + passed: 2, + failed: 1, + pending: 1, + durationMs: 2500 + }) + }) + + it('omits the pending count when nothing is pending', () => { + vi.useFakeTimers() + vi.setSystemTime(1000) + const { log, info } = fakeLog() + reportRunComplete( + { runStartTs: 1000, started: 1, passed: 1, failed: 0, pending: 0 }, + callbacks(), + log + ) + expect(info).toHaveBeenCalledWith( + '🧪 Test run complete: 1 passed, 0 failed (0.00s, 1 total)' + ) + }) +}) diff --git a/packages/service/src/invocation-args.ts b/packages/service/src/invocation-args.ts new file mode 100644 index 00000000..866d1aac --- /dev/null +++ b/packages/service/src/invocation-args.ts @@ -0,0 +1,46 @@ +import path from 'node:path' + +// A forked worker's argv lacks the config arg, so only the launcher can read it here. +export function configPathFromArgv(): string | undefined { + const argv = process.argv + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === '--config' || argv[i] === '-c') { + const next = argv[i + 1] + if (next && /\.(conf|config)\.(ts|js|cjs|mjs)$/i.test(next)) { + return path.isAbsolute(next) ? next : path.resolve(process.cwd(), next) + } + } + } + const positional = argv.find((a) => /\.conf\.(ts|js|cjs|mjs)$/i.test(a)) + if (!positional) { + return undefined + } + return path.isAbsolute(positional) + ? positional + : path.resolve(process.cwd(), positional) +} + +export function detectInvocationSpecs(): string[] { + const argv = process.argv + const out: string[] = [] + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === '--spec' || argv[i] === '-s') { + const raw = argv[i + 1] + if (!raw) { + continue + } + for (const part of raw.split(',')) { + const trimmed = part.trim() + if (!trimmed) { + continue + } + out.push( + path.isAbsolute(trimmed) + ? trimmed + : path.resolve(process.cwd(), trimmed) + ) + } + } + } + return out +} diff --git a/packages/service/src/launcher.ts b/packages/service/src/launcher.ts index 91ae9100..e98bc04c 100644 --- a/packages/service/src/launcher.ts +++ b/packages/service/src/launcher.ts @@ -6,6 +6,7 @@ import logger from '@wdio/logger' import { REUSE_ENV, RUNNER_ENV } from '@wdio/devtools-shared' import { resolveRunId } from '@wdio/devtools-core' import { DEFAULT_LAUNCH_CAPS } from './constants.js' +import { configPathFromArgv, detectInvocationSpecs } from './invocation-args.js' import type { ServiceOptions, ExtendedCapabilities } from './types.js' const log = logger('@wdio/devtools-service:Launcher') @@ -37,52 +38,6 @@ if (process.env[REUSE_ENV.REUSE] === '1') { } } -// Lives in the launcher: forked workers have their own argv without the config arg. -// Exported for unit tests; not part of the package's public API surface. -export function detectInvocationConfigPath(): string | undefined { - const argv = process.argv - for (let i = 0; i < argv.length - 1; i++) { - if (argv[i] === '--config' || argv[i] === '-c') { - const next = argv[i + 1] - if (next && /\.(conf|config)\.(ts|js|cjs|mjs)$/i.test(next)) { - return path.isAbsolute(next) ? next : path.resolve(process.cwd(), next) - } - } - } - const positional = argv.find((a) => /\.conf\.(ts|js|cjs|mjs)$/i.test(a)) - if (!positional) { - return undefined - } - return path.isAbsolute(positional) - ? positional - : path.resolve(process.cwd(), positional) -} - -export function detectInvocationSpecs(): string[] { - const argv = process.argv - const out: string[] = [] - for (let i = 0; i < argv.length - 1; i++) { - if (argv[i] === '--spec' || argv[i] === '-s') { - const raw = argv[i + 1] - if (!raw) { - continue - } - for (const part of raw.split(',')) { - const trimmed = part.trim() - if (!trimmed) { - continue - } - out.push( - path.isAbsolute(trimmed) - ? trimmed - : path.resolve(process.cwd(), trimmed) - ) - } - } - } - return out -} - export class DevToolsAppLauncher { #options: ServiceOptions #browser?: WebdriverIO.Browser @@ -92,7 +47,7 @@ export class DevToolsAppLauncher { } #captureRerunEnv(): void { - const detectedConfig = detectInvocationConfigPath() + const detectedConfig = configPathFromArgv() if (detectedConfig && !process.env[RUNNER_ENV.WDIO_CONFIG]) { process.env[RUNNER_ENV.WDIO_CONFIG] = detectedConfig log.info(`Detected config for reruns: ${detectedConfig}`) diff --git a/packages/service/src/screencast.ts b/packages/service/src/screencast.ts index 33ff924a..e4ef12b7 100644 --- a/packages/service/src/screencast.ts +++ b/packages/service/src/screencast.ts @@ -1,6 +1,7 @@ import logger from '@wdio/logger' import { ScreencastRecorderBase, + type ScreencastLogger, errorMessage, withTimeout, SCREENCAST_HANDSHAKE_TIMEOUT_MS @@ -32,21 +33,7 @@ interface PuppeteerLike { export class ScreencastRecorder extends ScreencastRecorderBase { #cdpSession: CdpSessionLike | undefined = undefined - protected override onPollingStarted(intervalMs: number): void { - log.info( - `✓ Screencast recording started (polling mode, ${intervalMs} ms interval)` - ) - } - - protected override onPollingStopped(frameCount: number): void { - log.info(`✓ Screencast stopped — ${frameCount} frame(s) collected`) - } - - protected override onUnavailable(err: unknown): void { - log.warn( - `Screencast unavailable (${errorMessage(err)}). Recording skipped.` - ) - } + protected override readonly logger: ScreencastLogger = log protected override async takeScreenshot(): Promise { if (!this.driver) { diff --git a/packages/service/src/standalone.ts b/packages/service/src/standalone.ts index 2b78827d..24eb38fd 100644 --- a/packages/service/src/standalone.ts +++ b/packages/service/src/standalone.ts @@ -5,6 +5,7 @@ import { RUNNER_ENV } from '@wdio/devtools-shared' import DevToolsHookService from './index.js' import type { SessionCapturer } from './session.js' import { TraceType } from './types.js' +import { configPathFromArgv } from './invocation-args.js' /** * Resolve the WDIO config path from argv or `DEVTOOLS_WDIO_CONFIG`. The @@ -18,22 +19,7 @@ export function detectInvocationConfigPath(): string | undefined { ? envPath : path.resolve(process.cwd(), envPath) } - const argv = process.argv - for (let i = 0; i < argv.length - 1; i++) { - if (argv[i] === '--config' || argv[i] === '-c') { - const next = argv[i + 1] - if (next && /\.(conf|config)\.(ts|js|cjs|mjs)$/i.test(next)) { - return path.isAbsolute(next) ? next : path.resolve(process.cwd(), next) - } - } - } - const positional = argv.find((a) => /\.conf\.(ts|js|cjs|mjs)$/i.test(a)) - if (!positional) { - return undefined - } - return path.isAbsolute(positional) - ? positional - : path.resolve(process.cwd(), positional) + return configPathFromArgv() } /** Sent on the capturer that has a socket: `beforeSession` sees a placeholder diff --git a/packages/service/tests/launcher.test.ts b/packages/service/tests/launcher.test.ts index cdec411a..c3a45e83 100644 --- a/packages/service/tests/launcher.test.ts +++ b/packages/service/tests/launcher.test.ts @@ -1,10 +1,10 @@ import path from 'node:path' import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' +import { DevToolsAppLauncher } from '../src/launcher.js' import { - DevToolsAppLauncher, - detectInvocationConfigPath, + configPathFromArgv, detectInvocationSpecs -} from '../src/launcher.js' +} from '../src/invocation-args.js' import * as backend from '@wdio/devtools-backend' import { remote } from 'webdriverio' @@ -296,7 +296,7 @@ describe('DevToolsAppLauncher', () => { }) }) - describe('detectInvocationConfigPath', () => { + describe('configPathFromArgv', () => { let originalArgv: string[] beforeEach(() => { originalArgv = process.argv @@ -307,13 +307,13 @@ describe('DevToolsAppLauncher', () => { it('returns undefined when no --config is present', () => { process.argv = ['node', 'cli'] - expect(detectInvocationConfigPath()).toBeUndefined() + expect(configPathFromArgv()).toBeUndefined() process.argv = originalArgv }) it('resolves --config against cwd when relative', () => { process.argv = ['node', 'cli', '--config', 'wdio.conf.ts'] - expect(detectInvocationConfigPath()).toBe( + expect(configPathFromArgv()).toBe( path.resolve(process.cwd(), 'wdio.conf.ts') ) process.argv = originalArgv @@ -322,19 +322,19 @@ describe('DevToolsAppLauncher', () => { it('returns absolute --config path verbatim', () => { const abs = '/abs/wdio.conf.cjs' process.argv = ['node', 'cli', '-c', abs] - expect(detectInvocationConfigPath()).toBe(abs) + expect(configPathFromArgv()).toBe(abs) process.argv = originalArgv }) it('ignores --config when value does not match the conf naming pattern', () => { process.argv = ['node', 'cli', '--config', 'not-a-conf.txt'] - expect(detectInvocationConfigPath()).toBeUndefined() + expect(configPathFromArgv()).toBeUndefined() process.argv = originalArgv }) it('finds a positional .conf.* path when no --config flag', () => { process.argv = ['node', 'cli', 'wdio.conf.ts', '--spec', 's'] - expect(detectInvocationConfigPath()).toBe( + expect(configPathFromArgv()).toBe( path.resolve(process.cwd(), 'wdio.conf.ts') ) process.argv = originalArgv