diff --git a/eslint.config.cjs b/eslint.config.cjs index 0119fee..f037455 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -77,7 +77,7 @@ module.exports = [ rules: { '@typescript-eslint/no-unused-vars': [ 'error', - { argsIgnorePattern: '^_' } + { argsIgnorePattern: '^_', ignoreRestSiblings: true } ], '@typescript-eslint/consistent-type-imports': 'error', 'no-unused-vars': 'off', diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts index 7401cae..3cbc907 100644 --- a/example/wdio.conf.ts +++ b/example/wdio.conf.ts @@ -63,13 +63,13 @@ export const config: Options.Testrunner = { capabilities: [ { browserName: 'chrome', - browserVersion: '144.0.7559.60', // specify chromium browser version for testing + browserVersion: '146.0.7680.72', // specify chromium browser version for testing 'goog:chromeOptions': { args: [ '--headless', '--disable-gpu', '--remote-allow-origins=*', - '--window-size=1280,800' + '--window-size=1600,1200' ] } // }, { diff --git a/package.json b/package.json index 20ae232..1cf55c4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "pnpm --parallel build", "demo": "wdio run ./example/wdio.conf.ts", + "demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example", "dev": "pnpm --parallel dev", "preview": "pnpm --parallel preview", "test": "vitest run", @@ -17,7 +18,10 @@ "pnpm": { "overrides": { "vite": "^7.3.0" - } + }, + "ignoredBuiltDependencies": [ + "chromedriver" + ] }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 493bb8c..1f1c190 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -6,14 +6,13 @@ import { TraceType, type TraceLog } from '@wdio/devtools-service/types' import { Element } from '@core/element' import { DataManagerController } from './controller/DataManager.js' import { DragController, Direction } from './utils/DragController.js' +import { SIDEBAR_MIN_WIDTH } from './controller/constants.js' import './components/header.js' import './components/sidebar.js' import './components/workbench.js' import './components/onboarding/start.js' -const SIDEBAR_MIN_WIDTH = 250 - @customElement('wdio-devtools') export class WebdriverIODevtoolsApplication extends Element { dataManager = new DataManagerController(this) @@ -71,8 +70,12 @@ export class WebdriverIODevtoolsApplication extends Element { this.requestUpdate() } - #clearExecutionData({ detail }: { detail?: { uid?: string } }) { - this.dataManager.clearExecutionData(detail?.uid) + #clearExecutionData({ + detail + }: { + detail?: { uid?: string; entryType?: 'suite' | 'test' } + }) { + this.dataManager.clearExecutionData(detail?.uid, detail?.entryType) } #mainContent() { diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index d852f1a..436de78 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -9,10 +9,10 @@ import type { CommandLog } from '@wdio/devtools-service/types' import { mutationContext, - type TraceMutation, metadataContext, - type Metadata -} from '../../controller/DataManager.js' + commandContext +} from '../../controller/context.js' +import type { Metadata } from '@wdio/devtools-service/types' import '~icons/mdi/world.js' import '../placeholder.js' @@ -20,15 +20,16 @@ import '../placeholder.js' const MUTATION_SELECTOR = '__mutation-highlight__' function transform(node: any): VNode<{}> { - if (typeof node !== 'object') { + if (typeof node !== 'object' || node === null) { + // Plain string/number text node — return as-is for Preact to render as text. return node as VNode<{}> } - const { children, ...props } = node.props + const { children, ...props } = node.props ?? {} /** * ToDo(Christian): fix way we collect data on added nodes in script */ - if (!node.type && children.type) { + if (!node.type && children?.type) { return transform(children) } @@ -44,6 +45,8 @@ const COMPONENT = 'wdio-devtools-browser' export class DevtoolsBrowser extends Element { #vdom = document.createDocumentFragment() #activeUrl?: string + /** Base64 PNG of the screenshot for the currently selected command, or null. */ + #screenshotData: string | null = null @consume({ context: metadataContext, subscribe: true }) metadata: Metadata | undefined = undefined @@ -51,6 +54,9 @@ export class DevtoolsBrowser extends Element { @consume({ context: mutationContext, subscribe: true }) mutations: TraceMutation[] = [] + @consume({ context: commandContext, subscribe: true }) + commands: CommandLog[] = [] + static styles = [ ...Element.styles, css` @@ -112,6 +118,31 @@ export class DevtoolsBrowser extends Element { border-radius: 0 0 0.5rem 0.5rem; min-height: 0; } + + .screenshot-overlay { + position: absolute; + inset: 0; + background: #111; + display: flex; + align-items: flex-start; + justify-content: center; + border-radius: 0 0 0.5rem 0.5rem; + overflow: hidden; + } + + .screenshot-overlay img { + max-width: 100%; + height: auto; + display: block; + } + + .iframe-wrapper { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } ` ] @@ -148,9 +179,16 @@ export class DevtoolsBrowser extends Element { return } + // viewport may not be serialized yet (race between metadata message and + // first resize event), or may arrive without dimensions — fall back to + // sensible defaults so we never throw. + const viewportWidth = (metadata.viewport as any)?.width || 1280 + const viewportHeight = (metadata.viewport as any)?.height || 800 + if (!viewportWidth || !viewportHeight) { + return + } + this.iframe.removeAttribute('style') - const viewportWidth = metadata.viewport.width - const viewportHeight = metadata.viewport.height const frameSize = this.getBoundingClientRect() const headerSize = this.header.getBoundingClientRect() @@ -178,23 +216,8 @@ export class DevtoolsBrowser extends Element { ) async #renderCommandScreenshot(command?: CommandLog) { - const screenshot = command?.screenshot - if (!screenshot) { - return - } - - if (!this.iframe) { - await this.updateComplete - } - if (!this.iframe) { - return - } - - this.iframe.srcdoc = ` - - - - ` + this.#screenshotData = command?.screenshot ?? null + this.requestUpdate() } async #renderNewDocument(doc: SimplifiedVNode, baseUrl: string) { @@ -270,7 +293,12 @@ export class DevtoolsBrowser extends Element { #handleChildListMutation(mutation: TraceMutation) { if (mutation.addedNodes.length === 1 && !mutation.target) { - const baseUrl = this.metadata?.url || 'unknown' + // Prefer the URL embedded in the mutation itself (set by the injected script + // at capture time), then fall back to the already-resolved active URL, and + // finally to the context metadata URL. This avoids a race where metadata + // arrives after the first childList mutation fires #renderNewDocument. + const baseUrl = + mutation.url || this.#activeUrl || this.metadata?.url || 'unknown' this.#renderNewDocument( mutation.addedNodes[0] as SimplifiedVNode, baseUrl @@ -389,6 +417,19 @@ export class DevtoolsBrowser extends Element { this.requestUpdate() } + /** Latest screenshot from any command — auto-updates the preview as tests run. */ + get #latestAutoScreenshot(): string | null { + if (!this.commands?.length) { + return null + } + for (let i = this.commands.length - 1; i >= 0; i--) { + if (this.commands[i].screenshot) { + return this.commands[i].screenshot! + } + } + return null + } + render() { /** * render a browser state if it hasn't before @@ -398,6 +439,10 @@ export class DevtoolsBrowser extends Element { this.#renderBrowserState() } + const hasMutations = this.mutations && this.mutations.length + const autoScreenshot = hasMutations ? null : this.#latestAutoScreenshot + const displayScreenshot = this.#screenshotData ?? autoScreenshot + return html`
${this.#activeUrl} - ${this.mutations && this.mutations.length - ? html`` - : html``} + ${this.#screenshotData + ? html`
+
+ +
+
` + : hasMutations + ? html`
+ +
` + : displayScreenshot + ? html`
+
+ +
+
` + : html``}
` } diff --git a/packages/app/src/components/header.ts b/packages/app/src/components/header.ts index 6d15f2b..45d666a 100644 --- a/packages/app/src/components/header.ts +++ b/packages/app/src/components/header.ts @@ -8,8 +8,8 @@ import '~icons/mdi/moon-waning-crescent.js' import '~icons/mdi/file-upload-outline.js' import './inputs/traceLoader.js' +import { DARK_MODE_KEY } from '../controller/constants.js' -const DARK_MODE_KEY = 'darkMode' const darkModeInitValue = localStorage.getItem(DARK_MODE_KEY) @customElement('wdio-devtools-header') diff --git a/packages/app/src/components/onboarding/start.ts b/packages/app/src/components/onboarding/start.ts index bb069a9..4a7136e 100644 --- a/packages/app/src/components/onboarding/start.ts +++ b/packages/app/src/components/onboarding/start.ts @@ -1,6 +1,6 @@ import { Element } from '@core/element' import { html, css } from 'lit' -import { customElement, property } from 'lit/decorators.js' +import { customElement } from 'lit/decorators.js' import '../inputs/traceLoader.js' @@ -23,9 +23,6 @@ export class DevtoolsStart extends Element { ` ] - @property() - onLoad = (content: any) => content - render() { return html`
diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts index 85ff538..97c7d2c 100644 --- a/packages/app/src/components/sidebar/constants.ts +++ b/packages/app/src/components/sidebar/constants.ts @@ -1,10 +1,25 @@ +import { TestState } from './types.js' + +export const STATE_MAP: Record = { + running: TestState.RUNNING, + failed: TestState.FAILED, + passed: TestState.PASSED, + skipped: TestState.SKIPPED +} import type { RunCapabilities } from './types.js' export const DEFAULT_CAPABILITIES: RunCapabilities = { canRunSuites: true, - canRunTests: true + canRunTests: true, + canRunAll: true } export const FRAMEWORK_CAPABILITIES: Record = { - cucumber: { canRunSuites: true, canRunTests: false } + cucumber: { canRunSuites: true, canRunTests: false, canRunAll: true }, + 'nightwatch-cucumber': { + canRunSuites: true, + canRunTests: false, + canRunAll: false + }, + nightwatch: { canRunSuites: true, canRunTests: true, canRunAll: false } } diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 0d2835f..44daeee 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -1,15 +1,14 @@ import { Element } from '@core/element' import { html, css, nothing, type TemplateResult } from 'lit' -import { customElement } from 'lit/decorators.js' +import { customElement, property } from 'lit/decorators.js' import { consume } from '@lit/context' -import type { TestStats, SuiteStats } from '@wdio/reporter' import type { Metadata } from '@wdio/devtools-service/types' import { repeat } from 'lit/directives/repeat.js' -import { - suiteContext, - metadataContext, - isTestRunningContext -} from '../../controller/DataManager.js' +import { suiteContext, metadataContext } from '../../controller/context.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../controller/types.js' import type { TestEntry, RunCapabilities, @@ -17,7 +16,11 @@ import type { TestRunDetail } from './types.js' import { TestState } from './types.js' -import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js' +import { + DEFAULT_CAPABILITIES, + FRAMEWORK_CAPABILITIES, + STATE_MAP +} from './constants.js' import '~icons/mdi/play.js' import '~icons/mdi/stop.js' @@ -63,13 +66,15 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ] @consume({ context: suiteContext, subscribe: true }) - suites: Record[] | undefined = undefined + @property({ type: Array }) + suites: Record[] | undefined = undefined @consume({ context: metadataContext, subscribe: true }) metadata: Metadata | undefined = undefined - @consume({ context: isTestRunningContext, subscribe: true }) - isTestRunning = false + updated(changedProperties: Map) { + super.updated(changedProperties) + } connectedCallback(): void { super.connectedCallback() @@ -104,7 +109,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { // Clear execution data before triggering rerun this.dispatchEvent( new CustomEvent('clear-execution-data', { - detail: { uid: detail.uid }, + detail: { uid: detail.uid, entryType: detail.entryType }, bubbles: true, composed: true }) @@ -183,7 +188,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { // Clear execution data and mark all tests as running this.dispatchEvent( new CustomEvent('clear-execution-data', { - detail: { uid: '*' }, + detail: { uid: '*', entryType: 'suite' }, bubbles: true, composed: true }) @@ -276,7 +281,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return html` @@ -326,24 +332,162 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ) } - #getTestEntry(entry: TestStats | SuiteStats): TestEntry { + #isRunning(entry: TestStatsFragment | SuiteStatsFragment): boolean { + if ('tests' in entry) { + // Fastest path: any explicitly running descendant + if ( + (entry.tests ?? []).some((t) => t.state === 'running') || + (entry.suites ?? []).some((s) => this.#isRunning(s)) + ) { + return true + } + + const hasPendingTests = (entry.tests ?? []).some( + (t) => t.state === 'pending' + ) + const hasPendingSuites = (entry.suites ?? []).some((s) => + this.#hasPending(s) + ) + const suiteState = entry.state + + // If the suite was explicitly marked 'running' (e.g. by markTestAsRunning) + // and still has pending children, it's actively executing. + if (suiteState === 'running' && (hasPendingTests || hasPendingSuites)) { + return true + } + + // Mixed terminal + pending children = run is in progress regardless of + // explicit suite state (handles Nightwatch Cucumber where the feature + // suite state may be undefined in the JSON payload). + const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] + const hasSomeTerminal = allDescendants.some( + (t) => + t.state === 'passed' || t.state === 'failed' || t.state === 'skipped' + ) + if ((hasPendingTests || hasPendingSuites) && hasSomeTerminal) { + return true + } + + return false + } + // For individual tests rely on explicit state only. + return entry.state === 'running' + } + + #hasPending(entry: TestStatsFragment | SuiteStatsFragment): boolean { if ('tests' in entry) { - const entries = [...entry.tests, ...entry.suites] + if (entry.state === 'pending') { + return true + } + if ((entry.tests ?? []).some((t) => t.state === 'pending')) { + return true + } + if ((entry.suites ?? []).some((s) => this.#hasPending(s))) { + return true + } + return false + } + return entry.state === 'pending' + } + + #hasFailed(entry: TestStatsFragment | SuiteStatsFragment): boolean { + if ('tests' in entry) { + // Check if any immediate test failed + if ((entry.tests ?? []).find((t) => t.state === 'failed')) { + return true + } + // Check if any nested suite has failures + if ((entry.suites ?? []).some((s) => this.#hasFailed(s))) { + return true + } + return false + } + // For individual tests + return entry.state === 'failed' + } + + #computeEntryState( + entry: TestStatsFragment | SuiteStatsFragment + ): TestState | 'pending' { + // For suites, check running state from children FIRST — this ensures that + // a rerun (which clears end times) shows the spinner immediately, even if + // the suite still has a cached 'passed'/'failed' state from the previous run. + if ('tests' in entry && this.#isRunning(entry)) { + return TestState.RUNNING + } + + const state = entry.state + + // A suite with an explicit 'pending' state is always in-progress from the + // UI's perspective — the backend uses 'pending' to signal a new run is + // starting. Skip the children check: stale terminal children from the + // previous run must not cause the suite to appear as passed. + if ('tests' in entry && state === 'pending') { + return TestState.RUNNING + } + + // For suites with no explicit terminal state, derive from children. + // A suite with state=undefined or state=running that has no terminal + // children yet is still in-progress — don't show PASSED prematurely. + if ('tests' in entry && (state === null || state === 'running')) { + const allDescendants = [...(entry.tests ?? []), ...(entry.suites ?? [])] + if (allDescendants.length > 0) { + const allTerminal = allDescendants.every( + (t) => + t.state === 'passed' || + t.state === 'failed' || + t.state === 'skipped' + ) + if (!allTerminal) { + // Still has non-terminal children — treat as running/loading + return TestState.RUNNING + } + } + } + + // Check explicit terminal state + const mappedState = state ? STATE_MAP[state] : undefined + if (mappedState) { + return mappedState + } + + // For suites, compute state from children + if ('tests' in entry) { + if (this.#hasFailed(entry)) { + return TestState.FAILED + } + return TestState.PASSED + } + + // For individual leaf tests: pending = spinner (run is in progress), + // not circle (which implies "never run"). + if (state === 'pending') { + return TestState.RUNNING + } + + return entry.end ? TestState.PASSED : 'pending' + } + + #getTestEntry(entry: TestStatsFragment | SuiteStatsFragment): TestEntry { + if ('tests' in entry) { + const entries = [...(entry.tests ?? []), ...(entry.suites ?? [])] + // A suite whose children are themselves suites is a feature/file-level + // container (Cucumber feature or test file). Tag it as 'feature' so the + // backend runner can distinguish it from a scenario/spec-level suite and + // avoid applying a --name filter that would match no scenarios. + const hasChildSuites = entry.suites && entry.suites.length > 0 + const derivedType = hasChildSuites ? 'feature' : entry.type || 'suite' return { uid: entry.uid, - label: entry.title, + label: entry.title ?? '', type: 'suite', - state: entry.tests.some((t) => !t.end) - ? TestState.RUNNING - : entry.tests.find((t) => t.state === 'failed') - ? TestState.FAILED - : TestState.PASSED, - callSource: (entry as any).callSource, - specFile: (entry as any).file, - fullTitle: entry.title, - featureFile: (entry as any).featureFile, - featureLine: (entry as any).featureLine, - suiteType: (entry as any).type, + state: this.#computeEntryState(entry), + callSource: entry.callSource, + specFile: entry.file, + fullTitle: entry.title ?? '', + featureFile: entry.featureFile, + featureLine: entry.featureLine, + suiteType: derivedType, children: Object.values(entries) .map(this.#getTestEntry.bind(this)) .filter(this.#filterEntry.bind(this)) @@ -351,18 +495,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { } return { uid: entry.uid, - label: entry.title, + label: entry.title ?? '', type: 'test', - state: !entry.end - ? TestState.RUNNING - : entry.state === 'failed' - ? TestState.FAILED - : TestState.PASSED, - callSource: (entry as any).callSource, - specFile: (entry as any).file, - fullTitle: (entry as any).fullTitle || entry.title, - featureFile: (entry as any).featureFile, - featureLine: (entry as any).featureLine, + state: this.#computeEntryState(entry), + callSource: entry.callSource, + specFile: entry.file, + fullTitle: entry.fullTitle || entry.title, + featureFile: entry.featureFile, + featureLine: entry.featureLine, children: [] } } @@ -391,16 +531,32 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {