From 3a7e997a300baeb2a0a9d9ec2403a7a44455d83b Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 12 Jun 2026 15:30:19 +0530 Subject: [PATCH 01/22] feat(app): make sidebar status chips the single-select test filter --- packages/app/src/components/sidebar.ts | 2 + .../app/src/components/sidebar/explorer.ts | 45 ++-- packages/app/src/components/sidebar/filter.ts | 114 +------- .../src/components/sidebar/suite-summary.ts | 105 ++++++++ .../app/src/components/sidebar/summary.ts | 254 ++++++++++++++++++ .../app/src/components/sidebar/tree-filter.ts | 40 +++ packages/app/src/components/sidebar/types.ts | 6 + packages/app/src/components/tabs.ts | 6 +- packages/app/src/core/colors.css | 14 + packages/app/src/tailwind.css | 3 + packages/app/src/vite-env.d.ts | 3 + packages/app/tests/suite-summary.test.ts | 146 ++++++++++ packages/app/tests/tree-filter.test.ts | 87 ++++++ 13 files changed, 687 insertions(+), 138 deletions(-) create mode 100644 packages/app/src/components/sidebar/suite-summary.ts create mode 100644 packages/app/src/components/sidebar/summary.ts create mode 100644 packages/app/src/components/sidebar/tree-filter.ts create mode 100644 packages/app/tests/suite-summary.test.ts create mode 100644 packages/app/tests/tree-filter.test.ts diff --git a/packages/app/src/components/sidebar.ts b/packages/app/src/components/sidebar.ts index de1a6272..dfce4238 100644 --- a/packages/app/src/components/sidebar.ts +++ b/packages/app/src/components/sidebar.ts @@ -3,6 +3,7 @@ import { html, css } from 'lit' import { customElement } from 'lit/decorators.js' import './sidebar/filter.js' +import './sidebar/summary.js' import './sidebar/explorer.js' @customElement('wdio-devtools-sidebar') @@ -25,6 +26,7 @@ export class DevtoolsSidebar extends Element { render() { return html` + ` } diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 3ee8a88b..983ade82 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -9,9 +9,10 @@ import type { SuiteStatsFragment, TestStatsFragment } from '../../controller/types.js' -import type { TestEntry, TestRunDetail } from './types.js' -import { TestState } from './types.js' +import type { TestEntry, TestRunDetail, StatusFilterDetail } from './types.js' +import type { TestStatus } from '@wdio/devtools-shared' import { getTestEntry } from './test-entry-state.js' +import { entryPassesFilter } from './tree-filter.js' import { getCapabilityWarning, getConfigPath, @@ -44,8 +45,10 @@ const EXPLORER = 'wdio-devtools-sidebar-explorer' @customElement(EXPLORER) export class DevtoolsSidebarExplorer extends CollapseableEntry { - #testFilter: DevtoolsSidebarFilter | undefined + #query = '' + #statusFilter: TestStatus | null = null #filterListener = this.#filterTests.bind(this) + #statusFilterListener = this.#applyStatusFilter.bind(this) #runListener = this.#handleTestRun.bind(this) #stopListener = this.#handleTestStop.bind(this) #preserveRerunListener = this.#handlePreserveAndRerun.bind(this) @@ -89,6 +92,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { connectedCallback(): void { super.connectedCallback() window.addEventListener('app-test-filter', this.#filterListener) + window.addEventListener('app-status-filter', this.#statusFilterListener) this.addEventListener('app-test-run', this.#runListener as EventListener) this.addEventListener('app-test-stop', this.#stopListener as EventListener) this.addEventListener( @@ -100,6 +104,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { disconnectedCallback(): void { super.disconnectedCallback() window.removeEventListener('app-test-filter', this.#filterListener) + window.removeEventListener('app-status-filter', this.#statusFilterListener) this.removeEventListener('app-test-run', this.#runListener as EventListener) this.removeEventListener( 'app-test-stop', @@ -112,7 +117,12 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { } #filterTests({ detail }: { detail: DevtoolsSidebarFilter }) { - this.#testFilter = detail + this.#query = detail.filterQuery + this.requestUpdate() + } + + #applyStatusFilter({ detail }: { detail: StatusFilterDetail }) { + this.#statusFilter = detail.status this.requestUpdate() } @@ -348,25 +358,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { } #filterEntry(entry: TestEntry): boolean { - if (!this.#testFilter) { - return true - } - - const entryLabelIncludingChildren = getSearchableLabel(entry) - .flat(Infinity) - .join(' ') - return ( - Boolean( - ['all', 'none'].includes(this.#testFilter.filterStatus) || - (entry.state === TestState.PASSED && this.#testFilter.filtersPassed) || - (entry.state === TestState.FAILED && this.#testFilter.filtersFailed) || - (entry.state === TestState.SKIPPED && this.#testFilter.filtersSkipped) - ) && - (!this.#testFilter.filterQuery || - entryLabelIncludingChildren - .toLowerCase() - .includes(this.#testFilter.filterQuery.toLowerCase())) - ) + return entryPassesFilter(entry, this.#query, this.#statusFilter) } #getTestEntry(entry: TestStatsFragment | SuiteStatsFragment): TestEntry { @@ -449,10 +441,3 @@ declare global { [EXPLORER]: DevtoolsSidebarExplorer } } - -function getSearchableLabel(entry: TestEntry): string[] { - if (entry.children.length === 0) { - return [entry.label] - } - return entry.children.flatMap(getSearchableLabel) -} diff --git a/packages/app/src/components/sidebar/filter.ts b/packages/app/src/components/sidebar/filter.ts index 7ee639ff..f5ebaff9 100644 --- a/packages/app/src/components/sidebar/filter.ts +++ b/packages/app/src/components/sidebar/filter.ts @@ -2,33 +2,18 @@ import { Element } from '@core/element' import { html, css } from 'lit' import { customElement, query } from 'lit/decorators.js' -import '~icons/mdi/chevron-right.js' - -enum FilterState { - PASSED = 1, - FAILED = 2, - SKIPPED = 4 -} - @customElement('wdio-devtools-sidebar-filter') export class DevtoolsSidebarFilter extends Element { - #filterState = 0 #filterQuery = '' - #isStateFilterOpen = false static styles = [ ...Element.styles, css` :host { width: 100%; - display: flex; - align-items: top; + display: block; font-size: 0.8em; - padding-right: 1em !important; - } - - label { - cursor: pointer; + padding: 0 0.75rem; } ` ] @@ -36,18 +21,6 @@ export class DevtoolsSidebarFilter extends Element { @query('input[name="filter"]') queryInput?: HTMLInputElement - #updateState(change: Event) { - const target = change.target as HTMLInputElement | null - if (!target) { - return - } - this.#filterState = target.checked - ? this.#filterState + Number(target.value) - : this.#filterState - Number(target.value) - this.requestUpdate() - this.#emitState() - } - #updateQuery() { if (!this.queryInput) { return @@ -56,11 +29,6 @@ export class DevtoolsSidebarFilter extends Element { this.#emitState() } - #toggleStateFilter() { - this.#isStateFilterOpen = !this.#isStateFilterOpen - this.requestUpdate() - } - #emitState() { window.dispatchEvent( new CustomEvent('app-test-filter', { @@ -71,83 +39,19 @@ export class DevtoolsSidebarFilter extends Element { ) } - get filtersPassed() { - return this.#filterState & FilterState.PASSED - } - get filtersFailed() { - return this.#filterState & FilterState.FAILED - } - get filtersSkipped() { - return this.#filterState & FilterState.SKIPPED - } - get filterStatus() { - if (this.filtersPassed && this.filtersFailed && this.filtersSkipped) { - return 'all' - } - - return ( - ['passed', 'failed', 'skipped'] - .filter( - (filter) => - this[ - `filters${filter.charAt(0).toUpperCase() + filter.slice(1)}` as keyof typeof this - ] - ) - .join(', ') || 'none' - ) - } get filterQuery() { return this.#filterQuery } - #renderStateCheckbox(state: FilterState, label: string, id: string) { - return html` -
  • - - -
  • - ` - } - render() { return html` - -
    - -
    - Status: - ${this.filterStatus} -
    -
    -
      - ${this.#renderStateCheckbox(FilterState.PASSED, 'Passed', 'passed')} - ${this.#renderStateCheckbox(FilterState.FAILED, 'Failed', 'failed')} - ${this.#renderStateCheckbox( - FilterState.SKIPPED, - 'Skipped', - 'skipped' - )} -
    -
    -
    + ` } } diff --git a/packages/app/src/components/sidebar/suite-summary.ts b/packages/app/src/components/sidebar/suite-summary.ts new file mode 100644 index 00000000..fabc7669 --- /dev/null +++ b/packages/app/src/components/sidebar/suite-summary.ts @@ -0,0 +1,105 @@ +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../controller/types.js' +import { TestState } from './types.js' + +export interface SuiteSummary { + passed: number + failed: number + running: number + skipped: number + pending: number + total: number +} + +export type RunStatus = 'running' | 'failed' | 'passed' | 'idle' + +const emptySummary = (): SuiteSummary => ({ + passed: 0, + failed: 0, + running: 0, + skipped: 0, + pending: 0, + total: 0 +}) + +function tally(test: TestStatsFragment, summary: SuiteSummary): void { + summary.total += 1 + switch (test.state) { + case TestState.PASSED: + summary.passed += 1 + break + case TestState.FAILED: + summary.failed += 1 + break + case TestState.RUNNING: + summary.running += 1 + break + case TestState.SKIPPED: + summary.skipped += 1 + break + default: + summary.pending += 1 + } +} + +function walk(suite: SuiteStatsFragment, summary: SuiteSummary): void { + for (const test of suite.tests ?? []) { + if (test) { + tally(test, summary) + } + } + for (const child of suite.suites ?? []) { + if (child) { + walk(child, summary) + } + } +} + +/** + * Count leaf tests by state across the suite tree. Roots are deduped by uid + * the same way the explorer renders them — nested suites carry a `parent` and + * are reached via recursion, so counting only roots avoids double-counting the + * flat registry entries. + */ +export function computeSuiteSummary( + suites: Record[] | undefined +): SuiteSummary { + const summary = emptySummary() + if (!suites) { + return summary + } + const roots = suites + .flatMap((chunk) => Object.values(chunk)) + .filter((suite) => suite && !suite.parent) + const unique = Array.from( + new Map(roots.map((suite) => [suite.uid, suite])).values() + ) + for (const suite of unique) { + walk(suite, summary) + } + return summary +} + +/** + * The headline run state shown in the status pill. Running wins over a stale + * terminal count (a rerun leaves old passed/failed values until results + * arrive); a finished run is failed if any test failed, otherwise passed. + */ +export function deriveRunStatus(summary: SuiteSummary): RunStatus { + const terminal = summary.passed + summary.failed + summary.skipped + if (summary.total === 0) { + return 'idle' + } + if (summary.running > 0 || (summary.pending > 0 && terminal > 0)) { + return 'running' + } + if (summary.failed > 0) { + return 'failed' + } + if (terminal === 0) { + return 'idle' + } + return 'passed' +} diff --git a/packages/app/src/components/sidebar/summary.ts b/packages/app/src/components/sidebar/summary.ts new file mode 100644 index 00000000..fd3e104f --- /dev/null +++ b/packages/app/src/components/sidebar/summary.ts @@ -0,0 +1,254 @@ +import { Element } from '@core/element' +import { html, css, nothing, type TemplateResult } from 'lit' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import type { TestStatus } from '@wdio/devtools-shared' +import { suiteContext } from '../../controller/context.js' +import type { SuiteStatsFragment } from '../../controller/types.js' +import { TestState } from './types.js' +import { + computeSuiteSummary, + deriveRunStatus, + type RunStatus, + type SuiteSummary +} from './suite-summary.js' + +const SUMMARY = 'wdio-devtools-sidebar-summary' + +const STATUS_LABEL: Record = { + running: 'Running', + failed: 'Failed', + passed: 'Passed', + idle: 'Idle' +} + +const STATUS_CHIPS = [ + { status: TestState.PASSED, label: 'Passed' }, + { status: TestState.FAILED, label: 'Failed' }, + { status: TestState.RUNNING, label: 'Running' }, + { status: TestState.SKIPPED, label: 'Skipped' } +] as const + +@customElement(SUMMARY) +export class DevtoolsSidebarSummary extends Element { + static styles = [ + ...Element.styles, + css` + :host { + display: block; + padding: 0 0.75rem 0.75rem; + font-size: 0.8em; + } + + .card { + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + padding: 0.625rem 0.75rem; + background: var(--vscode-editorWidget-background); + } + + .row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + .pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + font-weight: 700; + background: color-mix(in srgb, var(--status) 18%, transparent); + color: var(--status); + } + + .pill .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--status); + } + + :host([data-status='running']) .pill .dot { + animation: pulse 1.6s infinite; + } + + @keyframes pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--status) 60%, transparent); + } + 70% { + box-shadow: 0 0 0 7px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } + } + + .count { + color: var(--vscode-foreground); + } + .count b { + font-weight: 700; + } + + .progress { + display: flex; + height: 6px; + border-radius: 999px; + overflow: hidden; + background: var(--vscode-panel-border); + } + .progress > span { + height: 100%; + } + .seg-passed { + background: var(--vscode-charts-green); + } + .seg-failed { + background: var(--vscode-charts-red); + } + .seg-running { + background: var(--vscode-charts-blue); + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.7rem; + margin-top: 0.625rem; + } + .legend button { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0; + border: none; + background: none; + cursor: pointer; + white-space: nowrap; + font-size: inherit; + color: var(--vscode-descriptionForeground); + transition: color 0.12s; + } + .legend button:hover { + color: var(--vscode-foreground); + } + .legend button[aria-pressed='true'] { + color: var(--accent); + font-weight: 700; + } + .legend i { + width: 8px; + height: 8px; + border-radius: 2px; + flex: none; + } + .legend .passed i { + background: var(--vscode-charts-green); + } + .legend .failed i { + background: var(--vscode-charts-red); + } + .legend .running i { + background: var(--vscode-charts-blue); + } + .legend .skipped i { + background: var(--vscode-charts-yellow); + } + ` + ] + + @consume({ context: suiteContext, subscribe: true }) + @property({ type: Array }) + suites: Record[] | undefined = undefined + + @state() + private activeStatus: TestStatus | null = null + + #statusColor(status: RunStatus): string { + switch (status) { + case 'failed': + return 'var(--vscode-charts-red)' + case 'passed': + return 'var(--vscode-charts-green)' + case 'running': + return 'var(--vscode-charts-blue)' + default: + return 'var(--vscode-descriptionForeground)' + } + } + + #toggleStatus(status: TestStatus): void { + this.activeStatus = this.activeStatus === status ? null : status + window.dispatchEvent( + new CustomEvent('app-status-filter', { + bubbles: true, + composed: true, + detail: { status: this.activeStatus } + }) + ) + } + + #renderProgress(summary: SuiteSummary): TemplateResult { + const pct = (n: number) => `${(n / summary.total) * 100}%` + return html` +
    + + + +
    + ` + } + + #renderLegend(): TemplateResult { + return html` +
    + ${STATUS_CHIPS.map( + (chip) => html` + + ` + )} +
    + ` + } + + render() { + const summary = computeSuiteSummary(this.suites) + if (summary.total === 0) { + return nothing + } + const status = deriveRunStatus(summary) + this.setAttribute('data-status', status) + this.style.setProperty('--status', this.#statusColor(status)) + + return html` +
    +
    + ${STATUS_LABEL[status]} + + ${summary.passed}/${summary.total} passed + +
    + ${this.#renderProgress(summary)} ${this.#renderLegend()} +
    + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [SUMMARY]: DevtoolsSidebarSummary + } +} diff --git a/packages/app/src/components/sidebar/tree-filter.ts b/packages/app/src/components/sidebar/tree-filter.ts new file mode 100644 index 00000000..308fbe81 --- /dev/null +++ b/packages/app/src/components/sidebar/tree-filter.ts @@ -0,0 +1,40 @@ +import type { TestStatus } from '@wdio/devtools-shared' +import type { TestEntry } from './types.js' + +/** Flatten a tree entry to the labels searched by the text filter: a leaf + * contributes its own label, a suite contributes its descendants' labels. */ +export function getSearchableLabel(entry: TestEntry): string[] { + if (entry.children.length === 0) { + return [entry.label] + } + return entry.children.flatMap(getSearchableLabel) +} + +/** + * Decide whether a tree entry survives the active filters. Children are + * filtered before their parent, so a suite that still has children kept its + * matching descendants and stays visible to keep them reachable — only leaves + * are matched against the status directly. + */ +export function entryPassesFilter( + entry: TestEntry, + query: string, + status: TestStatus | null +): boolean { + const queryMatches = + !query || + getSearchableLabel(entry) + .join(' ') + .toLowerCase() + .includes(query.toLowerCase()) + if (!queryMatches) { + return false + } + if (!status) { + return true + } + if (entry.children.length > 0) { + return true + } + return entry.state === status +} diff --git a/packages/app/src/components/sidebar/types.ts b/packages/app/src/components/sidebar/types.ts index cf72e77d..5375ba9c 100644 --- a/packages/app/src/components/sidebar/types.ts +++ b/packages/app/src/components/sidebar/types.ts @@ -43,6 +43,12 @@ export interface TestRunDetail { import type { TestStatus } from '@wdio/devtools-shared' +/** Detail for the `app-status-filter` event: the single status the tree is + * narrowed to, or null when no status filter is active. */ +export interface StatusFilterDetail { + status: TestStatus | null +} + /** * Enum-style accessor for the canonical TestStatus values. Use the * shared TestStatus type for type annotations; this object is for diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index 2762a327..5b24d36f 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -37,10 +37,10 @@ export class DevtoolsTabs extends Element { return html` - @@ -416,7 +430,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { .filter(this.#filterEntry.bind(this)) return html`
    -

    +

    Tests

    ${this.#renderHeaderToolbar()} @@ -426,7 +442,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ? repeat( suites, (suite) => suite.uid, - (suite) => this.#renderEntry(suite) + (suite) => this.#renderEntry(suite, true) ) : html`

    No tests to display

    diff --git a/packages/app/src/components/sidebar/filter.ts b/packages/app/src/components/sidebar/filter.ts index ffcd3c24..18bdb85b 100644 --- a/packages/app/src/components/sidebar/filter.ts +++ b/packages/app/src/components/sidebar/filter.ts @@ -14,7 +14,7 @@ export class DevtoolsSidebarFilter extends Element { :host { display: block; width: 100%; - font-size: 0.8em; + font-size: 12px; } .field { diff --git a/packages/app/src/components/sidebar/summary.ts b/packages/app/src/components/sidebar/summary.ts index 85abfd22..7e5416c3 100644 --- a/packages/app/src/components/sidebar/summary.ts +++ b/packages/app/src/components/sidebar/summary.ts @@ -36,7 +36,7 @@ export class DevtoolsSidebarSummary extends Element { css` :host { display: block; - font-size: 0.8em; + font-size: 12px; } .card { @@ -120,6 +120,7 @@ export class DevtoolsSidebarSummary extends Element { flex-wrap: wrap; gap: 0.4rem 0.5rem; margin-top: 0.625rem; + font-size: 0.85em; } .legend button { display: inline-flex; diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 576d6969..2864b6aa 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -6,17 +6,15 @@ import { CollapseableEntry } from './collapseableEntry.js' import type { TestRunDetail, TestStatus } from './types.js' import { TestState } from './types.js' -import '~icons/mdi/chevron-right.js' +import '~icons/mdi/menu-down.js' import '~icons/mdi/play.js' import '~icons/mdi/stop.js' -import '~icons/mdi/eye.js' import '~icons/mdi/collapse-all.js' import '~icons/mdi/expand-all.js' -import '~icons/mdi/autorenew.js' -import '~icons/mdi/window-close.js' +import '~icons/mdi/close.js' import '~icons/mdi/debug-step-over.js' import '~icons/mdi/check.js' -import '~icons/mdi/checkbox-blank-circle-outline.js' +import '~icons/mdi/circle-outline.js' import '~icons/mdi/bug-play.js' const TEST_SUITE = 'wdio-test-suite' @@ -84,12 +82,71 @@ export class ExplorerTestEntry extends CollapseableEntry { @property({ type: Boolean, attribute: 'has-children' }) hasChildren = false + @property({ type: Boolean, reflect: true }) + selected = false + + @property({ type: Boolean, reflect: true }) + root = false + static styles = [ ...Element.styles, css` :host { display: block; - font-size: 0.8em; + font-size: 12.5px; + } + + /* The label is slotted, so its size must be set on the slotted node + directly — :host font-size doesn't reach it. */ + ::slotted(label) { + font-size: 12.5px; + } + + :host([selected]) .row { + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: inset 2px 0 0 var(--accent); + } + + /* Leaf rows (steps / test cases) are muted; running/failed/selected + pop — so the in-progress step stands out, like the mockup. */ + :host(:not([has-children])) ::slotted(label) { + color: var(--vscode-descriptionForeground); + } + :host([state='running']) ::slotted(label) { + color: var(--vscode-charts-blue); + } + :host([state='failed']) ::slotted(label) { + color: var(--vscode-charts-red); + } + :host([state='skipped']) ::slotted(label) { + color: var(--vscode-charts-yellow); + } + :host([selected]) ::slotted(label) { + color: var(--vscode-foreground); + } + /* Top-level feature/suite stays a neutral, bold heading like the mockup. */ + :host([root]) ::slotted(label) { + color: var(--vscode-foreground); + font-weight: 600; + } + + .run-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--vscode-charts-blue); + animation: run-pulse 1.5s ease-in-out infinite; + } + @keyframes run-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.7); + } } ` ] @@ -123,6 +180,19 @@ export class ExplorerTestEntry extends CollapseableEntry { ) } + #selectEntry() { + if (this.uid) { + this.dispatchEvent( + new CustomEvent('app-test-select', { + detail: this.uid, + bubbles: true, + composed: true + }) + ) + } + this.#viewSource() + } + #runEntry(event: Event) { event.stopPropagation() if (!this.uid || this.runDisabled) { @@ -209,9 +279,10 @@ export class ExplorerTestEntry extends CollapseableEntry { } get testStateIcon() { if (this.isRunning) { - return html`` + return html`` } if (this.hasPassed) { return html`` } if (this.hasFailed) { - return html`` + >` } if (this.hasSkipped) { return html`` } - return html`` + return html`` } #renderStopButton() { @@ -300,12 +371,6 @@ export class ExplorerTestEntry extends CollapseableEntry { class="flex-none ml-auto mr-1 transition-opacity opacity-0 group-hover/sidebar:opacity-100" > ${this.#renderRunStopButtons()} - ${!hasNoChildren ? html` - ${this.testStateIcon} + ${this.root ? nothing : this.testStateIcon} ${this.#renderToolbar(hasNoChildren)} -
    +
    ` diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index 5b24d36f..d4673f04 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -22,7 +22,9 @@ export class DevtoolsTabs extends Element { display: flex; flex-direction: column; color: var(--vscode-foreground); - background-color: var(--vscode-editor-background); + /* Panel shade — sits a step above the darker screencast/editor canvas + so the pane dividers read clearly, like the mockup. */ + background-color: var(--vscode-sideBar-background); } ` ] diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index 7f330157..574cb297 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -85,7 +85,7 @@ export class CommandItem extends ActionItem { @click="${() => this.#highlightLine()}" > ${this.iconChip(this.#renderIcon(entry.command))} - ${entry.command} ${this.renderTime()} diff --git a/packages/app/src/components/workbench/actionItems/mutation.ts b/packages/app/src/components/workbench/actionItems/mutation.ts index 5580ff1a..52721c33 100644 --- a/packages/app/src/components/workbench/actionItems/mutation.ts +++ b/packages/app/src/components/workbench/actionItems/mutation.ts @@ -99,7 +99,7 @@ export class MutationItem extends ActionItem { diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index d26ab69b..97c89486 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -30,6 +30,8 @@ export class DevtoolsSource extends Element { padding: 10px 0px; flex: 1; min-height: 0; + /* CodeMirror sets its own font size; match the mockup's 12.5px. */ + font-size: 12.5px; } .cm-content { padding: 0 !important; diff --git a/packages/app/src/controller/constants.ts b/packages/app/src/controller/constants.ts index e94f34f2..92b2f56b 100644 --- a/packages/app/src/controller/constants.ts +++ b/packages/app/src/controller/constants.ts @@ -1,5 +1,5 @@ export const CACHE_ID = 'wdio-trace-cache' -export const SIDEBAR_MIN_WIDTH = 320 +export const SIDEBAR_MIN_WIDTH = 250 export const DARK_MODE_KEY = 'darkMode' export const MIN_WORKBENCH_HEIGHT = Math.min(300, window.innerHeight * 0.3) export const MIN_METATAB_WIDTH = 260 diff --git a/packages/app/src/core/colors.css b/packages/app/src/core/colors.css index 655e489e..513f3877 100644 --- a/packages/app/src/core/colors.css +++ b/packages/app/src/core/colors.css @@ -5,10 +5,10 @@ @layer base { :host { - --vscode-font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, "Ubuntu", "Droid Sans", sans-serif; --vscode-font-weight: normal; --vscode-font-size: 13px; - --vscode-editor-font-family: "Droid Sans Mono", "monospace", monospace; + --vscode-editor-font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Droid Sans Mono", monospace; --vscode-editor-font-weight: normal; --vscode-editor-font-size: 14px; --vscode-foreground: #616161; @@ -546,10 +546,10 @@ } :host-context(.dark) { - --vscode-font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, "Ubuntu", "Droid Sans", sans-serif; --vscode-font-weight: normal; --vscode-font-size: 13px; - --vscode-editor-font-family: "Droid Sans Mono", "monospace", monospace; + --vscode-editor-font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Droid Sans Mono", monospace; --vscode-editor-font-weight: normal; --vscode-editor-font-size: 14px; --vscode-foreground: #cccccc; @@ -566,7 +566,7 @@ --vscode-textBlockQuote-border: rgba(0, 122, 204, 0.5); --vscode-textCodeBlock-background: rgba(10, 10, 10, 0.4); --vscode-widget-shadow: rgba(0, 0, 0, 0.36); - --vscode-input-background: #3c3c3c; + --vscode-input-background: #1c2128; --vscode-input-foreground: #cccccc; --vscode-inputOption-activeBorder: #007acc; --vscode-inputOption-hoverBackground: rgba(90, 93, 94, 0.5); @@ -604,11 +604,11 @@ --vscode-editorInfo-foreground: #3794ff; --vscode-editorHint-foreground: rgba(238, 238, 238, 0.7); --vscode-sash-hoverBorder: #007fd4; - --vscode-editor-background: #1e1e1e; + --vscode-editor-background: #0d0f12; --vscode-editor-foreground: #d4d4d4; --vscode-editorStickyScroll-background: #1e1e1e; --vscode-editorStickyScrollHover-background: #2a2d2e; - --vscode-editorWidget-background: #252526; + --vscode-editorWidget-background: #1c2128; --vscode-editorWidget-foreground: #cccccc; --vscode-editorWidget-border: #454545; --vscode-quickInput-background: #252526; @@ -852,8 +852,8 @@ --vscode-editorGroup-dropIntoPromptBackground: #252526; --vscode-sideBySideEditor-horizontalBorder: #444444; --vscode-sideBySideEditor-verticalBorder: #444444; - --vscode-panel-background: #1e1e1e; - --vscode-panel-border: rgba(128, 128, 128, 0.35); + --vscode-panel-background: #0d0f12; + --vscode-panel-border: #262b33; --vscode-panelTitle-activeForeground: #e7e7e7; --vscode-panelTitle-inactiveForeground: rgba(231, 231, 231, 0.6); --vscode-panelTitle-activeBorder: #e7e7e7; @@ -891,7 +891,7 @@ --vscode-statusBarItem-remoteForeground: #ffffff; --vscode-extensionBadge-remoteBackground: #007acc; --vscode-extensionBadge-remoteForeground: #ffffff; - --vscode-sideBar-background: #252526; + --vscode-sideBar-background: #14171c; --vscode-sideBarTitle-foreground: #bbbbbb; --vscode-sideBar-dropBackground: rgba(83, 89, 93, 0.5); --vscode-sideBarSectionHeader-background: rgba(0, 0, 0, 0); diff --git a/packages/app/src/core/core.css b/packages/app/src/core/core.css index a17e1c2e..df39c3ab 100644 --- a/packages/app/src/core/core.css +++ b/packages/app/src/core/core.css @@ -35,3 +35,43 @@ svg { width: 100%; height: 100%; } + +/* Resize sliders (DragController): a hairline that highlights with the accent + on hover/drag, like the mockup's gutters. The static pane border shows + underneath; this only adds the accent highlight. */ +button[data-draggable-id]::after { + content: ''; + position: absolute; + background: transparent; + transition: + background 0.12s, + width 0.12s, + height 0.12s; + pointer-events: none; +} +button[data-draggable-id].cursor-col-resize::after { + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); +} +button[data-draggable-id].cursor-row-resize::after { + left: 0; + right: 0; + top: 50%; + height: 1px; + transform: translateY(-50%); +} +button[data-draggable-id]:hover::after, +button[data-draggable-id][data-dragging='dragging']::after { + background: var(--accent); +} +button[data-draggable-id].cursor-col-resize:hover::after, +button[data-draggable-id].cursor-col-resize[data-dragging='dragging']::after { + width: 2px; +} +button[data-draggable-id].cursor-row-resize:hover::after, +button[data-draggable-id].cursor-row-resize[data-dragging='dragging']::after { + height: 2px; +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index ae9c9045..5dd8d6bb 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -12,6 +12,9 @@ body { place-items: center; min-width: 320px; min-height: 100vh; + /* Match the mockup's 13px text base. Set on body (not html) so it only + scales inherited font size, leaving rem-based spacing at the 16px root. */ + font-size: 13px; } @media (prefers-color-scheme: light) { diff --git a/packages/app/src/tailwind.css b/packages/app/src/tailwind.css index f54fd440..eeda8af3 100644 --- a/packages/app/src/tailwind.css +++ b/packages/app/src/tailwind.css @@ -20,4 +20,13 @@ --color-accent: var(--accent); --color-accentHover: var(--accent-hover); --color-accentForeground: var(--accent-foreground); + + /* Match the mockup's text sizes without touching the spacing scale, so + font size and padding/gaps stay independent. */ + --text-xs: 11px; + --text-xs--line-height: 1.4; + --text-sm: 12.5px; + --text-sm--line-height: 1.45; + --text-base: 13px; + --text-base--line-height: 1.5; } diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index faff46a8..49577485 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -17,6 +17,7 @@ interface GlobalEventHandlersEventMap { 'app-status-filter': CustomEvent< import('./components/sidebar/types').StatusFilterDetail > + 'app-test-select': CustomEvent 'app-test-run': CustomEvent< import('./components/sidebar/test-suite').TestRunDetail > From f6dbe288d1b5a6ef2d5cf317d5fe4f02ffbab336 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 15 Jun 2026 13:41:24 +0530 Subject: [PATCH 06/22] fix(service,nightwatch): hide automation infobar on the dashboard window --- packages/nightwatch-devtools/src/run-lifecycle.ts | 9 ++++++++- packages/service/src/constants.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/nightwatch-devtools/src/run-lifecycle.ts b/packages/nightwatch-devtools/src/run-lifecycle.ts index a6d7e246..f26a3cfd 100644 --- a/packages/nightwatch-devtools/src/run-lifecycle.ts +++ b/packages/nightwatch-devtools/src/run-lifecycle.ts @@ -134,8 +134,15 @@ export async function openDevtoolsBrowser( '--no-first-run', '--no-default-browser-check' ] + }, + // Dashboard uses the Puppeteer-based 'devtools' protocol; drop the + // "controlled by automated test software" infobar by ignoring + // Puppeteer's --enable-automation default. (Runtime-valid capability + // not present in the type.) + 'wdio:devtoolsOptions': { + ignoreDefaultArgs: ['--enable-automation'] } - } + } as WebdriverIO.Capabilities }) await ctx.devtoolsBrowser.url(url) } catch (err) { diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts index eec10f5c..46e38878 100644 --- a/packages/service/src/constants.ts +++ b/packages/service/src/constants.ts @@ -21,15 +21,23 @@ export { LOG_SOURCES } from '@wdio/devtools-core' -export const DEFAULT_LAUNCH_CAPS: WebdriverIO.Capabilities = { +// The dashboard launches via the Puppeteer-based 'devtools' protocol, so the +// "controlled by automated test software" infobar (added by Puppeteer's +// --enable-automation default) is removed via ignoreDefaultArgs, not +// chromedriver's excludeSwitches. `wdio:devtoolsOptions` is honored at runtime +// but isn't in this WebdriverIO.Capabilities type, hence the assertion. +export const DEFAULT_LAUNCH_CAPS = { browserName: 'chrome', 'goog:chromeOptions': { // production: args: ['--window-size=1600,1200'] // development: // args: ['--window-size=1600,1200', '--auto-open-devtools-for-tabs'] + }, + 'wdio:devtoolsOptions': { + ignoreDefaultArgs: ['--enable-automation'] } -} +} as WebdriverIO.Capabilities export const INTERNAL_COMMANDS = [ 'emit', From 0ae49cd4acd7363e8e8590d1d6fa6e5a479b3f82 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 15 Jun 2026 14:39:42 +0530 Subject: [PATCH 07/22] feat: Screencast scrubber with action markers --- .../components/browser/screencast-player.ts | 304 ++++++++++++++++++ .../app/src/components/browser/scrubber.ts | 54 ++++ .../src/components/browser/snapshot-styles.ts | 9 - .../app/src/components/browser/snapshot.ts | 53 ++- packages/app/src/controller/DataManager.ts | 10 +- packages/app/tests/scrubber.test.ts | 83 +++++ packages/backend/src/index.ts | 31 +- packages/backend/src/video-range.ts | 34 ++ .../backend/src/worker-message-handler.ts | 16 +- packages/backend/tests/video-range.test.ts | 56 ++++ packages/core/src/finalize-screencast.ts | 3 +- packages/core/src/video-encoder.ts | 9 + packages/shared/src/types.ts | 3 + 13 files changed, 635 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/components/browser/screencast-player.ts create mode 100644 packages/app/src/components/browser/scrubber.ts create mode 100644 packages/app/tests/scrubber.test.ts create mode 100644 packages/backend/src/video-range.ts create mode 100644 packages/backend/tests/video-range.test.ts diff --git a/packages/app/src/components/browser/screencast-player.ts b/packages/app/src/components/browser/screencast-player.ts new file mode 100644 index 00000000..976ada4f --- /dev/null +++ b/packages/app/src/components/browser/screencast-player.ts @@ -0,0 +1,304 @@ +import { Element } from '@core/element' +import { css, html } from 'lit' +import { customElement, property, query, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import type { CommandLog } from '@wdio/devtools-shared' + +import { commandContext } from '../../controller/context.js' +import { computeMarkers, formatClock } from './scrubber.js' + +const COMPONENT = 'wdio-devtools-screencast-player' + +/** + * Screencast video player with a custom scrubber: play/pause, `m:ss / m:ss` + * time, an orange progress bar with drag-seek, and per-command markers pinned + * to the recording timeline. Owns the `
    ` diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 32c134d4..5b809bba 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -10,6 +10,9 @@ import '../placeholder.js' import './actionItems/command.js' import './actionItems/mutation.js' import { stepDurations } from './actionItems/duration.js' +import { activeTimestampAt } from './active-entry.js' + +type TimelineEntry = TraceMutation | CommandLog const SOURCE_COMPONENT = 'wdio-devtools-actions' @@ -56,9 +59,15 @@ export class DevtoolsActions extends Element { private activeTimestamp?: number #onShowCommand = (event: Event) => { - this.activeTimestamp = ( - event as CustomEvent<{ command?: CommandLog }> - ).detail?.command?.timestamp + const command = (event as CustomEvent<{ command?: CommandLog }>).detail + ?.command + this.activeTimestamp = command?.timestamp + // Explicit click → jump to the call site and surface the Source tab. + if (command?.callSource) { + window.dispatchEvent( + new CustomEvent('app-source-highlight', { detail: command.callSource }) + ) + } } #onSelectMutation = (event: Event) => { @@ -67,29 +76,73 @@ export class DevtoolsActions extends Element { ).detail?.timestamp } + // Screencast playback drives the highlight to the action at the current frame. + // Only acts when the active action changes, so the editor isn't re-scrolled on + // every timeupdate tick. + #onScreencastProgress = (event: Event) => { + const { time } = (event as CustomEvent<{ time: number }>).detail + const entries = this.#sortedEntries() + const timestamp = activeTimestampAt( + entries.map((entry) => entry.timestamp), + time + ) + if (timestamp === this.activeTimestamp) { + return + } + this.activeTimestamp = timestamp + const active = entries.find((entry) => entry.timestamp === timestamp) + if (active && 'command' in active && active.callSource) { + window.dispatchEvent( + new CustomEvent('app-source-track', { + detail: { callSource: active.callSource } + }) + ) + } + } + connectedCallback(): void { super.connectedCallback() window.addEventListener('show-command', this.#onShowCommand) window.addEventListener('app-mutation-select', this.#onSelectMutation) + window.addEventListener( + 'app-screencast-progress', + this.#onScreencastProgress + ) } disconnectedCallback(): void { super.disconnectedCallback() window.removeEventListener('show-command', this.#onShowCommand) window.removeEventListener('app-mutation-select', this.#onSelectMutation) + window.removeEventListener( + 'app-screencast-progress', + this.#onScreencastProgress + ) } - render() { - const mutations = this.mutations || [] - const commands = this.commands || [] - // Only show document-load mutations (childList with a url) in the actions - // list — individual node add/remove mutations are too noisy. - const visibleMutations = mutations.filter( + // Mutations + commands merged and ordered by time — the timeline's rows. + // Only document-load mutations (childList with a url) are shown; individual + // node add/remove mutations are too noisy. + #sortedEntries(): TimelineEntry[] { + const visibleMutations = (this.mutations || []).filter( (m) => m.type === 'childList' && Boolean(m.url) ) - const entries = [...visibleMutations, ...commands].sort( + return [...visibleMutations, ...(this.commands || [])].sort( (a, b) => a.timestamp - b.timestamp ) + } + + // Keep the action that's playing in view as the screencast scrubs. + updated(changed: Map): void { + if (changed.has('activeTimestamp') && this.activeTimestamp !== undefined) { + this.renderRoot + .querySelector('[active]') + ?.scrollIntoView({ block: 'nearest' }) + } + } + + render() { + const entries = this.#sortedEntries() if (!entries.length) { return html`` diff --git a/packages/app/src/components/workbench/active-entry.ts b/packages/app/src/components/workbench/active-entry.ts new file mode 100644 index 00000000..3cdd2ed2 --- /dev/null +++ b/packages/app/src/components/workbench/active-entry.ts @@ -0,0 +1,20 @@ +/** + * Given action timestamps sorted ascending and a playback time (wall-clock ms), + * return the timestamp of the action that is "current" — the latest one at or + * before `time`. Returns undefined when `time` precedes the first action, so + * nothing is highlighted before the first command runs. + */ +export function activeTimestampAt( + sortedTimestamps: number[], + time: number +): number | undefined { + let active: number | undefined + for (const ts of sortedTimestamps) { + if (ts <= time) { + active = ts + } else { + break + } + } + return active +} diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index 97c89486..a2bb30fa 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -55,12 +55,14 @@ export class DevtoolsSource extends Element { #activeFile?: string #tabObserver?: MutationObserver + #onHighlight = (ev: Event) => this.#highlightCallSource(ev) + #onTrack = (ev: Event) => this.#trackCallSource(ev) + connectedCallback(): void { super.connectedCallback() - window.addEventListener( - 'app-source-highlight', - this.#highlightCallSource.bind(this) - ) + window.addEventListener('app-source-highlight', this.#onHighlight) + // Passive line-follow during screencast playback — scroll only, no tab flip. + window.addEventListener('app-source-track', this.#onTrack) // Observe when the containing tab becomes active so CodeMirror can remeasure // after having been initialized while the tab was hidden (display:none). requestAnimationFrame(() => { @@ -85,6 +87,8 @@ export class DevtoolsSource extends Element { disconnectedCallback(): void { super.disconnectedCallback() + window.removeEventListener('app-source-highlight', this.#onHighlight) + window.removeEventListener('app-source-track', this.#onTrack) this.#editorView?.destroy() this.#editorView = undefined this.#tabObserver?.disconnect() @@ -161,7 +165,20 @@ export class DevtoolsSource extends Element { } #highlightCallSource(ev: Event) { - const [filePath, line] = (ev as CustomEvent).detail.split(':') + this.#applyCallSource((ev as CustomEvent).detail) + this.closest('wdio-devtools-tabs')?.activateTab('Source') + } + + // Passive variant for screencast playback: follow the line without stealing + // the active tab, so watching the video doesn't yank you off Console/Network. + #trackCallSource(ev: Event) { + this.#applyCallSource( + (ev as CustomEvent<{ callSource: string }>).detail.callSource + ) + } + + #applyCallSource(callSource: string) { + const [filePath, line] = callSource.split(':') // If the source for this file is already loaded, mount and scroll immediately if (this.sources?.[filePath]) { this.#mountEditor(filePath, parseInt(line, 10)) @@ -170,7 +187,6 @@ export class DevtoolsSource extends Element { // store desired highlight so we can apply it then. this.#activeFile = filePath } - this.closest('wdio-devtools-tabs')?.activateTab('Source') } render() { diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index 49577485..c8b0a9f0 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -10,6 +10,8 @@ interface GlobalEventHandlersEventMap { 'app-mutation-highlight': CustomEvent 'app-mutation-select': CustomEvent 'app-source-highlight': CustomEvent + 'app-source-track': CustomEvent<{ callSource: string }> + 'app-screencast-progress': CustomEvent<{ time: number }> 'app-test-filter': CustomEvent< import('./components/sidebar/filter').DevtoolsSidebarFilter diff --git a/packages/app/tests/active-entry.test.ts b/packages/app/tests/active-entry.test.ts new file mode 100644 index 00000000..9dcf416f --- /dev/null +++ b/packages/app/tests/active-entry.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' + +import { activeTimestampAt } from '../src/components/workbench/active-entry.js' + +describe('activeTimestampAt', () => { + const stamps = [100, 200, 300, 400] + + it('returns undefined before the first action', () => { + expect(activeTimestampAt(stamps, 50)).toBeUndefined() + }) + + it('returns the action exactly at the playback time', () => { + expect(activeTimestampAt(stamps, 200)).toBe(200) + }) + + it('returns the latest action at or before the playback time', () => { + expect(activeTimestampAt(stamps, 250)).toBe(200) + expect(activeTimestampAt(stamps, 399)).toBe(300) + }) + + it('clamps to the last action once playback passes it', () => { + expect(activeTimestampAt(stamps, 999)).toBe(400) + }) + + it('returns undefined for an empty timeline', () => { + expect(activeTimestampAt([], 100)).toBeUndefined() + }) +}) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a57b3ff2..fc718a15 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -168,8 +168,6 @@ export interface ScreencastInfo { videoFile?: string frameCount?: number duration?: number - /** Unix ms timestamp of the first recorded frame — lets the UI map action - * timestamps onto the video timeline. */ startTime?: number } From a636b11ff9b524418edb9f80226deccd99dcc7bd Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 15 Jun 2026 17:40:45 +0530 Subject: [PATCH 09/22] style: Align dashboard layout and components with redesign mockup --- packages/app/src/app.ts | 8 +- .../components/browser/screencast-player.ts | 2 +- .../src/components/browser/snapshot-styles.ts | 18 +++-- .../app/src/components/browser/snapshot.ts | 31 ++++++-- .../app/src/components/sidebar/explorer.ts | 23 +++++- .../app/src/components/sidebar/summary.ts | 7 +- .../app/src/components/sidebar/test-suite.ts | 3 - packages/app/src/components/tabs.ts | 22 ++++-- packages/app/src/components/workbench.ts | 77 ++++++++++++------- .../workbench/actionItems/category.ts | 5 -- .../components/workbench/actionItems/item.ts | 7 +- .../app/src/components/workbench/actions.ts | 5 +- .../app/src/components/workbench/source.ts | 1 - packages/app/src/controller/constants.ts | 3 + packages/app/src/core/core.css | 3 - packages/app/src/index.css | 2 - packages/app/src/tailwind.css | 2 - packages/app/tests/category.test.ts | 1 - 18 files changed, 150 insertions(+), 70 deletions(-) diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index c98fa1cf..788facb2 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -6,7 +6,11 @@ import { TraceType, type TraceLog } from '@wdio/devtools-shared' import { Element } from '@core/element' import { DataManagerController } from './controller/DataManager.js' import { DragController, Direction } from './utils/DragController.js' -import { SIDEBAR_MIN_WIDTH, DARK_MODE_KEY } from './controller/constants.js' +import { + SIDEBAR_MIN_WIDTH, + SIDEBAR_DEFAULT_WIDTH, + DARK_MODE_KEY +} from './controller/constants.js' import { POPOUT_QUERY } from './components/workbench/compare/constants.js' // Bootstrap the dark-mode class on as early as possible so popout @@ -65,7 +69,7 @@ export class WebdriverIODevtoolsApplication extends Element { #drag = new DragController(this, { localStorageKey: 'sidebarWidth', minPosition: SIDEBAR_MIN_WIDTH, - initialPosition: window.innerWidth * 0.2, + initialPosition: SIDEBAR_DEFAULT_WIDTH, getContainerEl: () => this.#getWindow(), direction: Direction.horizontal }) diff --git a/packages/app/src/components/browser/screencast-player.ts b/packages/app/src/components/browser/screencast-player.ts index 22385ecb..3a75843b 100644 --- a/packages/app/src/components/browser/screencast-player.ts +++ b/packages/app/src/components/browser/screencast-player.ts @@ -63,7 +63,7 @@ export class ScreencastPlayer extends Element { padding: 10px 14px; background: var(--vscode-sideBar-background); border-top: 1px solid var(--vscode-panel-border, #262b33); - border-radius: 0 0 0.5rem 0.5rem; + border-radius: 0 0 14px 14px; } .scrub-play { diff --git a/packages/app/src/components/browser/snapshot-styles.ts b/packages/app/src/components/browser/snapshot-styles.ts index ec6d7c75..a1fb75d3 100644 --- a/packages/app/src/components/browser/snapshot-styles.ts +++ b/packages/app/src/components/browser/snapshot-styles.ts @@ -7,10 +7,15 @@ export const snapshotStyles = css` width: 100%; height: 100%; display: flex; - padding: 2rem !important; + padding: 1.25rem !important; align-items: center; justify-content: center; box-sizing: border-box !important; + background: radial-gradient( + 120% 120% at 50% 0%, + var(--vscode-editorWidget-background), + var(--vscode-editor-background) + ); } section { @@ -23,6 +28,9 @@ export const snapshotStyles = css` background: var(--vscode-sideBar-background); padding: 0.5rem; gap: 0; + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.45), + 0 0 60px color-mix(in srgb, var(--accent) 12%, transparent); } .frame-dot { @@ -54,7 +62,7 @@ export const snapshotStyles = css` top: 0; left: 0; border: none; - border-radius: 0 0 0.5rem 0.5rem; + border-radius: 0 0 14px 14px; } .screenshot-overlay { @@ -64,7 +72,7 @@ export const snapshotStyles = css` display: flex; align-items: flex-start; justify-content: center; - border-radius: 0 0 0.5rem 0.5rem; + border-radius: 0 0 14px 14px; overflow: hidden; } @@ -106,8 +114,8 @@ export const snapshotStyles = css` } .view-toggle button.active { - background: var(--vscode-button-background, #0e639c); - color: var(--vscode-button-foreground, #fff); + background: var(--accent, #ff7a3c); + color: var(--accent-foreground, #0d0f12); border-color: transparent; } diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 01169ae8..58bf0ec6 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -16,6 +16,7 @@ import { import type { Metadata } from '@wdio/devtools-shared' import '~icons/mdi/world.js' +import '~icons/mdi/lock.js' import '../placeholder.js' import './screencast-player.js' @@ -137,6 +138,22 @@ export class DevtoolsBrowser extends Element { } #setIframeSize() { + if (!this.section || !this.header) { + return + } + // Screencast: let the device frame fill the pane and the video object-fit + // inside it, so the frame spans the column like the mockup regardless of + // the captured window's aspect ratio. Snapshot mode keeps its aspect-lock + // (the DOM-replay iframe is scaled to the captured viewport). + if (this.#viewMode === 'video') { + this.section.style.width = '100%' + this.section.style.height = '100%' + return + } + this.#sizeSnapshotToViewport() + } + + #sizeSnapshotToViewport() { const metadata = this.metadata if (!this.section || !this.header || !metadata) { return @@ -560,10 +577,10 @@ export class DevtoolsBrowser extends Element { const hasMutations = this.mutations && this.mutations.length return html`
    @@ -571,9 +588,13 @@ export class DevtoolsBrowser extends Element {
    - + ${this.#activeUrl?.startsWith('https') + ? html`` + : html``} ${this.#activeUrl}
    ${this.#renderViewToggle()} diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 06214c8c..d370d86c 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -47,6 +47,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { #query = '' #statusFilter: TestStatus | null = null #selectedUid?: string + #autoSelectedUid?: string #filterListener = this.#filterTests.bind(this) #statusFilterListener = this.#applyStatusFilter.bind(this) #selectListener = this.#handleSelect.bind(this) @@ -140,6 +141,24 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { this.requestUpdate() } + // Deepest running entry (a running step/test), so the highlight tracks the + // most specific in-progress row rather than its parent suite. + #findRunningUid(entries: TestEntry[]): string | undefined { + for (const entry of entries) { + const child = + entry.children && entry.children.length + ? this.#findRunningUid(entry.children) + : undefined + if (child) { + return child + } + if (entry.state === 'running') { + return entry.uid + } + } + return undefined + } + async #handleTestRun(event: Event) { event.stopPropagation() const detail = (event as CustomEvent).detail @@ -352,7 +371,8 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { feature-line="${entry.featureLine ?? ''}" suite-type="${entry.suiteType || ''}" ?has-children="${entry.children && entry.children.length > 0}" - ?selected="${entry.uid === this.#selectedUid}" + ?selected="${entry.uid === + (this.#selectedUid ?? this.#autoSelectedUid)}" ?root="${isRoot}" .runDisabled=${this.#isRunDisabled(entry)} .runDisabledReason=${this.#getRunDisabledReason(entry)} @@ -428,6 +448,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { const suites = uniqueSuites .map(this.#getTestEntry.bind(this)) .filter(this.#filterEntry.bind(this)) + this.#autoSelectedUid = this.#findRunningUid(suites) return html`

    ${tabId} ${showBadge diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 9159f82c..eb35e6c5 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -28,6 +28,8 @@ import './browser/snapshot.js' import { MIN_WORKBENCH_HEIGHT, MIN_METATAB_WIDTH, + ACTIONS_DEFAULT_WIDTH, + BROWSER_HEIGHT_RATIO, RERENDER_TIMEOUT } from '../controller/constants.js' @@ -57,7 +59,9 @@ export class DevtoolsWorkbench extends Element { display: flex; flex-direction: column; flex-grow: 1; - height: 100vh; + /* Fill the parent (calc(100% - header)); 100vh overflowed by the 40px + header height and clipped the bottom of the right column. */ + height: 100%; min-height: 0; overflow: hidden; color: var(--vscode-foreground); @@ -71,15 +75,20 @@ export class DevtoolsWorkbench extends Element { localStorageKey: 'toolbarHeight', minPosition: MIN_WORKBENCH_HEIGHT, maxPosition: window.innerHeight * 0.7, - initialPosition: window.innerHeight * 0.7, // initial height of browser window is 70% of window - getContainerEl: () => this as unknown as Element, + initialPosition: window.innerHeight * BROWSER_HEIGHT_RATIO, + getContainerEl: () => this.#getVerticalWindow(), direction: Direction.vertical }) + async #getVerticalWindow() { + await this.updateComplete + return this.verticalResizerWindow as Element + } + #dragHorizontal = new DragController(this, { localStorageKey: 'workbenchSidebarWidth', minPosition: MIN_METATAB_WIDTH, - initialPosition: MIN_METATAB_WIDTH, + initialPosition: ACTIONS_DEFAULT_WIDTH, getContainerEl: () => this.#getHorizontalWindow(), direction: Direction.horizontal }) @@ -120,12 +129,17 @@ export class DevtoolsWorkbench extends Element { @query('section[data-horizontal-resizer-window]') horizontalResizerWindow?: HTMLElement - #computeWorkbenchPaneStyle(): string { + @query('section[data-vertical-resizer-window]') + verticalResizerWindow?: HTMLElement + + // Height of the screencast pane; the dock fills the rest of the right column. + // Collapsed dock → empty string so the browser flex-grows to fill. + #computeBrowserPaneStyle(): string { if (this.#toolbarCollapsed) { return '' } const m = this.#dragVertical.getPosition().match(/(\d+(?:\.\d+)?)px/) - const raw = m ? parseFloat(m[1]) : window.innerHeight * 0.7 + const raw = m ? parseFloat(m[1]) : window.innerHeight * BROWSER_HEIGHT_RATIO const capped = Math.min(raw, window.innerHeight * 0.7) const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, capped) return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:70vh; min-height:0;` @@ -165,16 +179,20 @@ export class DevtoolsWorkbench extends Element { - ${this.#workbenchSidebarCollapsed - ? html` - - ` - : nothing} + ` + } + + #renderSidebarRestoreButton() { + if (!this.#workbenchSidebarCollapsed) { + return nothing + } + return html` + ` } @@ -212,7 +230,7 @@ export class DevtoolsWorkbench extends Element { class="relative z-10 border-t-[1px] border-t-panelBorder ${this .#toolbarCollapsed ? 'hidden' - : ''} flex-1 min-h-0 pb-10" + : ''} flex-1 min-h-0" > @@ -251,12 +269,10 @@ export class DevtoolsWorkbench extends Element { } render() { - const heightWorkbench = this.#toolbarCollapsed ? 'h-full flex-1' : '' return html`
    ${this.#renderActionsSidebar()}
    + ${this.#renderSidebarRestoreButton()} ${!this.#workbenchSidebarCollapsed ? this.#dragHorizontal.getSlider('z-30') : nothing}
    - +
    + +
    + ${!this.#toolbarCollapsed + ? this.#dragVertical.getSlider( + 'z-[999] -mt-[5px] pointer-events-auto' + ) + : nothing} + ${this.#renderWorkbenchTabs()}
    - ${!this.#toolbarCollapsed - ? this.#dragVertical.getSlider('z-[999] -mt-[5px] pointer-events-auto') - : nothing} - ${this.#renderWorkbenchTabs()} ` } } diff --git a/packages/app/src/components/workbench/actionItems/category.ts b/packages/app/src/components/workbench/actionItems/category.ts index 548d2603..e40861a8 100644 --- a/packages/app/src/components/workbench/actionItems/category.ts +++ b/packages/app/src/components/workbench/actionItems/category.ts @@ -95,11 +95,6 @@ export function commandCategory(command: string): ActionCategory { return 'other' } -/** - * The mockup uses a distinct glyph per command intent — finer than the colour - * category (e.g. `$` and `getText` are both "query"-coloured but get a target - * vs. text icon). This maps a command to that glyph; unknowns fall to 'execute'. - */ export type ActionIcon = | 'navigate' | 'reload' diff --git a/packages/app/src/components/workbench/actionItems/item.ts b/packages/app/src/components/workbench/actionItems/item.ts index 99e52acd..931a2299 100644 --- a/packages/app/src/components/workbench/actionItems/item.ts +++ b/packages/app/src/components/workbench/actionItems/item.ts @@ -57,9 +57,12 @@ export class ActionItem extends Element { z-index: 1; } + button { + border-radius: 8px; + } :host([active]) button { - background: var(--vscode-list-inactiveSelectionBackground); - box-shadow: inset 2px 0 0 var(--accent); + background: var(--vscode-editorWidget-background); + box-shadow: inset 0 0 0 1px var(--vscode-panel-border); } :host([active]) .ic { border-color: var(--accent); diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 5b809bba..23ff8bf8 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -33,13 +33,16 @@ export class DevtoolsActions extends Element { position: relative; display: flex; flex-direction: column; + /* Gutter so the active row's rounded box is inset from the edges, + like the mockup, rather than spanning edge to edge. */ + padding: 0 8px; } /* Vertical rail threading the action icon chips. */ .timeline::before { content: ''; position: absolute; - left: 20px; + left: 28px; top: 18px; bottom: 18px; width: 1px; diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index a2bb30fa..2dcf757f 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -30,7 +30,6 @@ export class DevtoolsSource extends Element { padding: 10px 0px; flex: 1; min-height: 0; - /* CodeMirror sets its own font size; match the mockup's 12.5px. */ font-size: 12.5px; } .cm-content { diff --git a/packages/app/src/controller/constants.ts b/packages/app/src/controller/constants.ts index 92b2f56b..84679f34 100644 --- a/packages/app/src/controller/constants.ts +++ b/packages/app/src/controller/constants.ts @@ -4,6 +4,9 @@ export const DARK_MODE_KEY = 'darkMode' export const MIN_WORKBENCH_HEIGHT = Math.min(300, window.innerHeight * 0.3) export const MIN_METATAB_WIDTH = 260 export const RERENDER_TIMEOUT = 10 +export const SIDEBAR_DEFAULT_WIDTH = 350 +export const ACTIONS_DEFAULT_WIDTH = 360 +export const BROWSER_HEIGHT_RATIO = 1.4 / 2.4 export const LOG_ICONS: Record = { log: '📄', info: 'ℹ️', diff --git a/packages/app/src/core/core.css b/packages/app/src/core/core.css index df39c3ab..3871a220 100644 --- a/packages/app/src/core/core.css +++ b/packages/app/src/core/core.css @@ -36,9 +36,6 @@ svg { height: 100%; } -/* Resize sliders (DragController): a hairline that highlights with the accent - on hover/drag, like the mockup's gutters. The static pane border shows - underneath; this only adds the accent highlight. */ button[data-draggable-id]::after { content: ''; position: absolute; diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 5dd8d6bb..89fc8c2c 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -12,8 +12,6 @@ body { place-items: center; min-width: 320px; min-height: 100vh; - /* Match the mockup's 13px text base. Set on body (not html) so it only - scales inherited font size, leaving rem-based spacing at the 16px root. */ font-size: 13px; } diff --git a/packages/app/src/tailwind.css b/packages/app/src/tailwind.css index eeda8af3..014c3241 100644 --- a/packages/app/src/tailwind.css +++ b/packages/app/src/tailwind.css @@ -21,8 +21,6 @@ --color-accentHover: var(--accent-hover); --color-accentForeground: var(--accent-foreground); - /* Match the mockup's text sizes without touching the spacing scale, so - font size and padding/gaps stay independent. */ --text-xs: 11px; --text-xs--line-height: 1.4; --text-sm: 12.5px; diff --git a/packages/app/tests/category.test.ts b/packages/app/tests/category.test.ts index 043e408e..046b7752 100644 --- a/packages/app/tests/category.test.ts +++ b/packages/app/tests/category.test.ts @@ -39,7 +39,6 @@ describe('commandCategory', () => { describe('commandIcon', () => { it('distinguishes icons within the query category', () => { - // both are query-coloured, but get different glyphs like the mockup expect(commandIcon('$')).toBe('select') expect(commandIcon('getText')).toBe('read') }) From 485ffe6af0945a326ca855c240a5ba23f4d45170 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 15 Jun 2026 20:15:24 +0530 Subject: [PATCH 10/22] fix: Theme-adaptive light mode, segmented toggle, and screencast polish --- examples/nightwatch/nightwatch.conf.cjs | 2 +- examples/wdio/wdio.conf.ts | 2 +- .../components/browser/screencast-player.ts | 29 ++++++++--- .../src/components/browser/snapshot-styles.ts | 48 ++++++++++++------- .../app/src/components/browser/snapshot.ts | 2 +- packages/app/src/components/header.ts | 10 +++- .../app/src/components/workbench/source.ts | 46 ++++++++++++++++-- 7 files changed, 108 insertions(+), 31 deletions(-) diff --git a/examples/nightwatch/nightwatch.conf.cjs b/examples/nightwatch/nightwatch.conf.cjs index 7f14af67..2a969097 100644 --- a/examples/nightwatch/nightwatch.conf.cjs +++ b/examples/nightwatch/nightwatch.conf.cjs @@ -32,7 +32,7 @@ module.exports = { '--headless', '--no-sandbox', '--disable-dev-shm-usage', - '--window-size=1600,1200' + '--window-size=1600,900' ] }, 'goog:loggingPrefs': { performance: 'ALL' } diff --git a/examples/wdio/wdio.conf.ts b/examples/wdio/wdio.conf.ts index 00abe28a..0f7b3470 100644 --- a/examples/wdio/wdio.conf.ts +++ b/examples/wdio/wdio.conf.ts @@ -69,7 +69,7 @@ export const config: Options.Testrunner = { '--headless', '--disable-gpu', '--remote-allow-origins=*', - '--window-size=1600,1200' + '--window-size=1600,900' ] } // }, { diff --git a/packages/app/src/components/browser/screencast-player.ts b/packages/app/src/components/browser/screencast-player.ts index 3a75843b..7687d1c3 100644 --- a/packages/app/src/components/browser/screencast-player.ts +++ b/packages/app/src/components/browser/screencast-player.ts @@ -50,7 +50,9 @@ export class ScreencastPlayer extends Element { min-height: 0; width: 100%; object-fit: contain; - background: #111; + /* Letterbox bars track the theme (near-black in dark, light in light) + instead of a fixed #111 that looked wrong in light mode. */ + background: var(--vscode-editor-background, #111); display: block; cursor: pointer; } @@ -112,10 +114,12 @@ export class ScreencastPlayer extends Element { right: 0; height: 5px; border-radius: 999px; + /* Foreground-tinted so the unfilled track reads on both themes + (light track on dark, grey track on light). */ background: color-mix( in srgb, - var(--vscode-panel-border, #262b33) 70%, - #000 + var(--vscode-foreground) 14%, + transparent ); } @@ -138,8 +142,12 @@ export class ScreencastPlayer extends Element { height: 13px; border-radius: 50%; background: #fff; - box-shadow: 0 0 0 4px - color-mix(in srgb, var(--accent, #ff7a3c) 35%, transparent); + /* Inner ring gives the white knob an edge on light tracks; outer ring + is the accent halo. */ + box-shadow: + 0 0 0 1px + color-mix(in srgb, var(--vscode-foreground) 35%, transparent), + 0 0 0 4px color-mix(in srgb, var(--accent, #ff7a3c) 35%, transparent); transform: translateX(-50%); pointer-events: none; } @@ -198,7 +206,12 @@ export class ScreencastPlayer extends Element { #onTime = () => { this.currentTime = this.video?.currentTime ?? 0 - this.#emitProgress() + // Only follow during actual playback — the browser fires a timeupdate at + // 0:00 when the video first loads, which would otherwise highlight the + // first action the moment a recording appears (e.g. on run completion). + if (this.playing) { + this.#emitProgress() + } } /** @@ -234,6 +247,10 @@ export class ScreencastPlayer extends Element { } const clamped = Math.max(0, Math.min(1, fraction)) video.currentTime = clamped * video.duration + // Update the timeline highlight immediately on an explicit seek, even while + // paused (timeupdate only emits during playback now). + this.currentTime = video.currentTime + this.#emitProgress() } #seekFromPointer(ev: PointerEvent) { diff --git a/packages/app/src/components/browser/snapshot-styles.ts b/packages/app/src/components/browser/snapshot-styles.ts index a1fb75d3..664b5776 100644 --- a/packages/app/src/components/browser/snapshot-styles.ts +++ b/packages/app/src/components/browser/snapshot-styles.ts @@ -68,7 +68,7 @@ export const snapshotStyles = css` .screenshot-overlay { position: absolute; inset: 0; - background: #111; + background: var(--vscode-editor-background, #111); display: flex; align-items: flex-start; justify-content: center; @@ -91,44 +91,58 @@ export const snapshotStyles = css` flex-direction: column; } + /* Segmented control like the mockup: the border lives on the group; the + buttons are borderless pills inside a small inset. */ .view-toggle { display: flex; - gap: 2px; + gap: 0; margin-left: 0.5rem; flex-shrink: 0; + padding: 2px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: var(--vscode-input-background); } .view-toggle button { - padding: 2px 10px; + padding: 5px 11px; font-size: 11px; + font-weight: 600; font-family: inherit; - border: 1px solid var(--vscode-editorSuggestWidget-border, #454545); + border: none; background: transparent; - color: var(--vscode-input-foreground, #ccc); + color: var(--vscode-descriptionForeground, #ccc); cursor: pointer; - border-radius: 3px; - line-height: 20px; + border-radius: 6px; + line-height: 1; transition: - background 0.1s, - color 0.1s; + background-color 0.18s ease, + color 0.18s ease; + } + + .view-toggle button:hover { + color: var(--vscode-foreground); } .view-toggle button.active { background: var(--accent, #ff7a3c); color: var(--accent-foreground, #0d0f12); - border-color: transparent; + } + + .view-toggle button.active:hover { + color: var(--accent-foreground, #0d0f12); } .video-select { font-size: 11px; font-family: inherit; - padding: 2px 4px; - border: 1px solid var(--vscode-dropdown-border, #454545); - border-radius: 3px; - background: var(--vscode-dropdown-background, #3c3c3c); - color: var(--vscode-dropdown-foreground, #ccc); + padding: 5px 8px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); cursor: pointer; - line-height: 20px; - margin-left: 4px; + line-height: 1; + margin-left: 6px; } ` diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 58bf0ec6..9b912f58 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -501,7 +501,7 @@ export class DevtoolsBrowser extends Element { > Screencast - ${this.#videos.length > 1 + ${this.#videos.length > 1 && this.#viewMode === 'video' ? html` { + this.selectedSessionId = (e.target as HTMLSelectElement).value + }} + > + ${sessions.map( + ([id, meta], i) => + html`` + )} + + ` + } + render() { - if (!this.metadata) { + const sessions = this.#sessions() + const active = this.#activeMetadata(sessions) as MetadataShape | undefined + if (!active) { return html`` } - const m = this.metadata as MetadataShape return html`
    - ${this.#renderSection('Session', this.#buildSessionInfo(m))} - ${this.#renderSection('Capabilities', m.capabilities)} - ${this.#renderSection('Desired Capabilities', m.desiredCapabilities)} - ${this.#renderSection('Options', m.options)} + ${this.#renderSessionSelect(sessions)} + ${this.#renderSection('Session', this.#buildSessionInfo(active))} + ${this.#renderSection('Capabilities', active.capabilities)} + ${this.#renderSection( + 'Desired Capabilities', + active.desiredCapabilities + )} + ${this.#renderSection('Options', active.options)}
    ` } diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index c2e218c8..aa40b474 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -13,6 +13,7 @@ import { consoleLogContext, networkRequestContext, metadataContext, + metadataBySessionContext, commandContext, sourceContext, suiteContext, @@ -31,19 +32,29 @@ import { markRunningAsStopped } from './mark-running.js' import { shouldResetForNewRun } from './run-detection.js' -import { mergeNetworkRequests, replaceCommand } from './contextUpdates.js' +import { + mergeNetworkRequests, + replaceCommand, + mergeSessionMetadata +} from './contextUpdates.js' export class DataManagerController implements ReactiveController { #ws?: WebSocket #host: ReactiveControllerHost & HTMLElement #lastSeenRunTimestamp = 0 #activeRerunTestUid?: string + /** Most-recently-seen browser sessionId — target for metadata messages + * that arrive without their own sessionId (e.g. url updates). */ + #currentSessionId?: string mutationsContextProvider: ContextProvider logsContextProvider: ContextProvider consoleLogsContextProvider: ContextProvider networkRequestsContextProvider: ContextProvider metadataContextProvider: ContextProvider + metadataBySessionContextProvider: ContextProvider< + typeof metadataBySessionContext + > commandsContextProvider: ContextProvider sourcesContextProvider: ContextProvider suitesContextProvider: ContextProvider @@ -72,6 +83,10 @@ export class DataManagerController implements ReactiveController { this.metadataContextProvider = new ContextProvider(this.#host, { context: metadataContext }) + this.metadataBySessionContextProvider = new ContextProvider(this.#host, { + context: metadataBySessionContext, + initialValue: {} + }) this.commandsContextProvider = new ContextProvider(this.#host, { context: commandContext, initialValue: [] @@ -380,10 +395,16 @@ export class DataManagerController implements ReactiveController { } #handleMetadataUpdate(data: Metadata) { - this.metadataContextProvider.setValue({ - ...this.metadataContextProvider.value, - ...data - }) + const { bySession, currentSessionId, active } = mergeSessionMetadata( + { + bySession: this.metadataBySessionContextProvider.value || {}, + currentSessionId: this.#currentSessionId + }, + data + ) + this.#currentSessionId = currentSessionId + this.metadataBySessionContextProvider.setValue(bySession) + this.metadataContextProvider.setValue(active) } #handleSourcesUpdate(data: Record) { @@ -479,6 +500,13 @@ export class DataManagerController implements ReactiveController { traceFile.networkRequests || [] ) this.metadataContextProvider.setValue(traceFile.metadata) + // Trace files hold a single session; seed the per-session map so the + // Metadata tab shows it (keyed by sessionId when present). + const traceSessionId = traceFile.metadata?.sessionId + this.metadataBySessionContextProvider.setValue( + traceSessionId ? { [traceSessionId]: traceFile.metadata } : {} + ) + this.#currentSessionId = traceSessionId this.commandsContextProvider.setValue(traceFile.commands) this.sourcesContextProvider.setValue(traceFile.sources) this.suitesContextProvider.setValue( diff --git a/packages/app/src/controller/context.ts b/packages/app/src/controller/context.ts index 58979892..fe2f5766 100644 --- a/packages/app/src/controller/context.ts +++ b/packages/app/src/controller/context.ts @@ -1,6 +1,7 @@ import { createContext } from '@lit/context' import type { Metadata, + MetadataBySession, CommandLog, PreservedAttempt } from '@wdio/devtools-shared' @@ -19,6 +20,9 @@ export const networkRequestContext = createContext( export const metadataContext = createContext( Symbol('metadataContext') ) +export const metadataBySessionContext = createContext( + Symbol('metadataBySessionContext') +) export const commandContext = createContext( Symbol('commandContext') ) diff --git a/packages/app/src/controller/contextUpdates.ts b/packages/app/src/controller/contextUpdates.ts index a9b338b7..fb3dda4c 100644 --- a/packages/app/src/controller/contextUpdates.ts +++ b/packages/app/src/controller/contextUpdates.ts @@ -7,7 +7,12 @@ * new value the ContextProvider should publish. */ -import type { CommandLog, NetworkRequest } from '@wdio/devtools-shared' +import type { + CommandLog, + NetworkRequest, + Metadata, + MetadataBySession +} from '@wdio/devtools-shared' /** * Replace an existing command entry (matched first by stable `id`, then by @@ -67,3 +72,66 @@ export function mergeNetworkRequests( } return next } + +/** Key for metadata received before any `sessionId` is known; folded into the + * first real session once it arrives so early `{ url }`/viewport aren't lost. */ +export const PENDING_SESSION_KEY = '__pending__' + +export interface MetadataMergeState { + bySession: MetadataBySession + /** Most-recently-seen sessionId — target for messages without their own. */ + currentSessionId?: string +} + +export interface MetadataMergeResult extends MetadataMergeState { + /** Merged metadata for the resolved session — what `metadataContext` publishes. */ + active: Metadata +} + +/** + * Merge an incoming metadata message into a per-session map, immutably. + * + * Messages carry `sessionId` on session start but not on every update (e.g. a + * `url` change), so updates without one attribute to the current session. A + * message arriving before any session is known is buffered under + * {@link PENDING_SESSION_KEY} and folded into the first real session. + */ +export function mergeSessionMetadata( + state: MetadataMergeState, + incoming: Metadata +): MetadataMergeResult { + const bySession: MetadataBySession = { ...state.bySession } + // Boundary broadcasts can carry an empty-string sessionId; treat it as absent + // so it attributes to the current session instead of forging a ghost entry. + const incomingSessionId = incoming.sessionId || undefined + const sessionId = + incomingSessionId ?? state.currentSessionId ?? PENDING_SESSION_KEY + + // Drop empty/undefined fields so a later partial message (e.g. the + // session-start broadcast that carries `url: ''`) can't wipe a real value + // already captured for the session. + const updates = Object.fromEntries( + Object.entries(incoming).filter( + ([, v]) => v !== undefined && v !== null && v !== '' + ) + ) as Partial + + let merged: Metadata = { ...bySession[sessionId], ...updates } + + // First real session absorbs anything buffered before a sessionId existed. + if ( + incomingSessionId && + sessionId !== PENDING_SESSION_KEY && + bySession[PENDING_SESSION_KEY] + ) { + merged = { ...bySession[PENDING_SESSION_KEY], ...merged } + delete bySession[PENDING_SESSION_KEY] + } + + bySession[sessionId] = merged + return { + bySession, + currentSessionId: incomingSessionId ?? state.currentSessionId, + active: merged + } +} diff --git a/packages/app/tests/contextUpdates.test.ts b/packages/app/tests/contextUpdates.test.ts index 6389fb8a..6b53bd80 100644 --- a/packages/app/tests/contextUpdates.test.ts +++ b/packages/app/tests/contextUpdates.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' -import type { CommandLog, NetworkRequest } from '@wdio/devtools-shared' +import type { + CommandLog, + NetworkRequest, + Metadata +} from '@wdio/devtools-shared' import { mergeNetworkRequests, - replaceCommand + replaceCommand, + mergeSessionMetadata, + PENDING_SESSION_KEY } from '../src/controller/contextUpdates.js' function cmd( @@ -90,3 +96,94 @@ describe('mergeNetworkRequests', () => { expect(current).toHaveLength(1) }) }) + +const meta = (m: Partial): Metadata => m as Metadata + +describe('mergeSessionMetadata', () => { + it('creates an entry for a message carrying a sessionId', () => { + const r = mergeSessionMetadata( + { bySession: {} }, + meta({ sessionId: 's1', url: '/a' }) + ) + expect(r.currentSessionId).toBe('s1') + expect(r.bySession).toEqual({ s1: { sessionId: 's1', url: '/a' } }) + expect(r.active).toEqual({ sessionId: 's1', url: '/a' }) + }) + + it('merges a sessionId-less update into the current session', () => { + const first = mergeSessionMetadata( + { bySession: {} }, + meta({ sessionId: 's1', url: '/a', capabilities: { browserName: 'chrome' } }) + ) + const next = mergeSessionMetadata(first, meta({ url: '/secure' })) + // url updates, capabilities preserved (the overwrite regression) + expect(next.bySession.s1).toEqual({ + sessionId: 's1', + url: '/secure', + capabilities: { browserName: 'chrome' } + }) + expect(next.currentSessionId).toBe('s1') + }) + + it('keeps a second session independent of the first', () => { + const first = mergeSessionMetadata( + { bySession: {} }, + meta({ sessionId: 's1', url: '/a' }) + ) + const second = mergeSessionMetadata(first, meta({ sessionId: 's2', url: '/b' })) + expect(second.bySession.s1).toEqual({ sessionId: 's1', url: '/a' }) + expect(second.bySession.s2).toEqual({ sessionId: 's2', url: '/b' }) + expect(second.active).toEqual({ sessionId: 's2', url: '/b' }) + }) + + it('buffers under PENDING_SESSION_KEY then folds into the first session', () => { + const pending = mergeSessionMetadata({ bySession: {} }, meta({ url: '/early' })) + expect(pending.bySession[PENDING_SESSION_KEY]).toEqual({ url: '/early' }) + expect(pending.currentSessionId).toBeUndefined() + + const resolved = mergeSessionMetadata( + pending, + meta({ sessionId: 's1', capabilities: { browserName: 'chrome' } }) + ) + expect(resolved.bySession[PENDING_SESSION_KEY]).toBeUndefined() + expect(resolved.bySession.s1).toEqual({ + url: '/early', + sessionId: 's1', + capabilities: { browserName: 'chrome' } + }) + }) + + it('does not let an empty url clobber a real one (re-broadcast)', () => { + const withUrl = mergeSessionMetadata( + { bySession: {} }, + meta({ sessionId: 's1', url: 'https://example.com' }) + ) + // session-start re-broadcast carries url: '' — must not wipe the real url + const after = mergeSessionMetadata( + withUrl, + meta({ sessionId: 's1', url: '', capabilities: { browserName: 'chrome' } }) + ) + expect(after.bySession.s1.url).toBe('https://example.com') + expect(after.bySession.s1.capabilities).toEqual({ browserName: 'chrome' }) + }) + + it('treats an empty-string sessionId as absent (no ghost entry)', () => { + const first = mergeSessionMetadata( + { bySession: {} }, + meta({ sessionId: 's1', url: 'https://a' }) + ) + // boundary broadcast carries sessionId: '' — must attribute to current, + // not forge a '' key + const after = mergeSessionMetadata(first, meta({ sessionId: '' })) + expect(Object.keys(after.bySession)).toEqual(['s1']) + expect(after.currentSessionId).toBe('s1') + expect(after.bySession.s1.url).toBe('https://a') + }) + + it('does not mutate the input map', () => { + const state = { bySession: { s1: meta({ sessionId: 's1', url: '/a' }) } } + const next = mergeSessionMetadata(state, meta({ sessionId: 's2', url: '/b' })) + expect(next.bySession).not.toBe(state.bySession) + expect(Object.keys(state.bySession)).toEqual(['s1']) + }) +}) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fc718a15..4e11181d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -230,6 +230,11 @@ export interface Metadata { desiredCapabilities?: Record } +/** Captured metadata keyed by browser `sessionId` — lets the UI keep each + * session's metadata instead of overwriting on a new session. Record (not Map) + * so it stays JSON-serializable across the WS boundary and in contexts. */ +export type MetadataBySession = Record + /** * Node-safe shape of a captured DOM mutation. The browser-side script * (packages/script) extends this with the real `MutationRecordType` union From f2abcde829316cfff81e19105cf13a4a80f55105 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 12:53:41 +0530 Subject: [PATCH 14/22] feat: Console and Log new layout design --- packages/app/src/components/tabs.ts | 20 +- .../app/src/components/workbench/actions.ts | 7 +- .../components/workbench/console-filter.ts | 55 ++++ .../app/src/components/workbench/console.ts | 300 +++++++++++------- packages/app/src/components/workbench/logs.ts | 251 +++++++++++---- packages/app/src/controller/constants.ts | 18 +- packages/app/tests/console-filter.test.ts | 85 +++++ packages/app/tests/contextUpdates.test.ts | 27 +- 8 files changed, 576 insertions(+), 187 deletions(-) create mode 100644 packages/app/src/components/workbench/console-filter.ts create mode 100644 packages/app/tests/console-filter.test.ts diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index b66ceb85..ccda011a 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -38,6 +38,18 @@ export class DevtoolsTabs extends Element { color: var(--vscode-foreground); font-weight: 600; } + .tab-badge { + font-size: 11px; + line-height: 1.4; + padding: 1px 7px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--vscode-foreground) 10%, + transparent + ); + color: var(--vscode-descriptionForeground); + } ` ] @@ -57,13 +69,7 @@ export class DevtoolsTabs extends Element { : 'border-transparent'}" > ${tabId} - ${showBadge - ? html`${badge}` - : nothing} + ${showBadge ? html`${badge}` : nothing} ` } diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index f7a52db3..76e6d369 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -63,10 +63,13 @@ export class DevtoolsActions extends Element { const command = (event as CustomEvent<{ command?: CommandLog }>).detail ?.command this.activeTimestamp = command?.timestamp - // Explicit click → jump to the call site and surface the Source tab. + // Follow the call site in the Source editor passively — the Log tab is what + // surfaces on a command click, so stealing focus to Source would flash. if (command?.callSource) { window.dispatchEvent( - new CustomEvent('app-source-highlight', { detail: command.callSource }) + new CustomEvent('app-source-track', { + detail: { callSource: command.callSource } + }) ) } } diff --git a/packages/app/src/components/workbench/console-filter.ts b/packages/app/src/components/workbench/console-filter.ts new file mode 100644 index 00000000..9dcf6378 --- /dev/null +++ b/packages/app/src/components/workbench/console-filter.ts @@ -0,0 +1,55 @@ +/** + * Pure helpers for the Console tab's search + level filtering, split out so the + * matching logic can be unit-tested without rendering the component. + */ + +/** Level filter options — `all` plus one per captured log type. */ +export type ConsoleLevelFilter = 'all' | 'error' | 'warn' | 'info' | 'log' + +// SGR escape sequences (e.g. ``) from the WDIO terminal logger render +// as stray `[31m` once the invisible ESC is dropped — strip them for display. +const ANSI_SGR_RE = /\[[0-9;]*m/g + +/** Remove terminal ANSI color codes so logger output reads cleanly in the UI. */ +export function stripAnsi(value: string): string { + return value.replace(ANSI_SGR_RE, '') +} + +/** Render a log entry's args into one string for display and search. */ +export function formatConsoleArgs(args: unknown): string { + if (!Array.isArray(args)) { + return stripAnsi(String(args)) + } + return stripAnsi( + args + .map((arg) => { + if (typeof arg === 'string') { + return arg + } + try { + return JSON.stringify(arg, null, 2) + } catch { + return String(arg) + } + }) + .join(' ') + ) +} + +/** Filter logs by level and a case-insensitive substring of the message. */ +export function filterConsoleLogs( + logs: ConsoleLogs[], + level: ConsoleLevelFilter, + search: string +): ConsoleLogs[] { + const needle = search.trim().toLowerCase() + return logs.filter((log) => { + if (level !== 'all' && (log.type || 'log') !== level) { + return false + } + if (needle && !formatConsoleArgs(log.args).toLowerCase().includes(needle)) { + return false + } + return true + }) +} diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index 09d451a6..12cf5424 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -1,10 +1,24 @@ import { Element } from '@core/element' -import { html, css, nothing } from 'lit' -import { customElement } from 'lit/decorators.js' +import { html, css } from 'lit' +import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' import { consoleLogContext } from '../../controller/context.js' -import { LOG_ICONS } from '../../controller/constants.js' +import { LOG_ICONS, CONSOLE_SOURCE_BADGE } from '../../controller/constants.js' +import { + filterConsoleLogs, + formatConsoleArgs, + type ConsoleLevelFilter +} from './console-filter.js' + +const LEVEL_FILTERS: ReadonlyArray<{ key: ConsoleLevelFilter; label: string }> = + [ + { key: 'all', label: 'All' }, + { key: 'error', label: 'Errors' }, + { key: 'warn', label: 'Warnings' }, + { key: 'info', label: 'Info' }, + { key: 'log', label: 'Logs' } + ] const SOURCE_COMPONENT = 'wdio-devtools-console-logs' @customElement(SOURCE_COMPONENT) @@ -21,89 +35,147 @@ export class DevtoolsConsoleLogs extends Element { position: relative; } + /* ── Toolbar: filter input + segmented level filter ── */ + .console-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + flex-shrink: 0; + } + .search-input { + flex: 1; + max-width: 280px; + padding: 6px 10px; + border: 1px solid var(--vscode-panel-border); + background: var(--vscode-input-background); + color: var(--vscode-foreground); + border-radius: 8px; + font-size: 12px; + } + .search-input::placeholder { + color: var(--vscode-descriptionForeground); + } + .search-input:focus { + outline: none; + border-color: var(--accent); + } + .filter-tabs { + display: flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: var(--vscode-input-background); + } + .filter-tab { + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 6px; + transition: + background-color 0.15s ease, + color 0.15s ease; + } + .filter-tab:hover { + color: var(--vscode-foreground); + } + .filter-tab.active { + background: var(--accent); + color: var(--accent-foreground); + } + + /* ── Log list ── */ .console-container { flex: 1; overflow-y: auto; - font-family: - 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, - 'Courier New', monospace; - font-size: 13px; - line-height: 1.6; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + padding: 4px 0; } .log-entry { - display: flex; - align-items: flex-start; - padding: 2px 8px; - border-bottom: 1px solid rgba(128, 128, 128, 0.05); - min-height: 22px; + display: grid; + grid-template-columns: 46px 16px auto 1fr; + align-items: baseline; + gap: 10px; + padding: 4px 14px; + border-bottom: 1px solid var(--vscode-panel-border); + line-height: 1.55; } - .log-entry:hover { - background-color: rgba(255, 255, 255, 0.02); + background-color: var(--vscode-list-hoverBackground); } - .log-entry.log-type-error { - background-color: rgba(244, 135, 113, 0.03); + background-color: color-mix( + in srgb, + var(--vscode-charts-red) 6%, + transparent + ); } - .log-entry.log-type-warn { - background-color: rgba(205, 151, 49, 0.03); - } - - .log-entry.log-type-info { - background-color: rgba(14, 99, 156, 0.03); - } - - .log-time, - .log-icon { - flex-shrink: 0; + background-color: color-mix( + in srgb, + var(--vscode-charts-yellow) 6%, + transparent + ); } .log-time { - width: 45px; text-align: right; - margin-right: 12px; - font-size: 11px; - opacity: 0.5; - user-select: none; + font-size: 10.5px; color: var(--vscode-editorLineNumber-foreground); - line-height: 18px; + font-variant-numeric: tabular-nums; + user-select: none; } - .log-icon { - margin-right: 8px; - font-size: 14px; - line-height: 18px; + text-align: center; + color: var(--vscode-descriptionForeground); } - - .log-prefix { - flex-shrink: 0; - color: var(--vscode-foreground); - opacity: 0.8; - margin-right: 4px; - font-weight: 600; + .log-entry.log-type-error .log-icon, + .log-entry.log-type-error .log-message { + color: var(--vscode-charts-red); } - - .log-prefix.source-test { - color: #4ec9b0; + .log-entry.log-type-warn .log-icon, + .log-entry.log-type-warn .log-message { + color: var(--vscode-charts-yellow); } - - .log-prefix.source-terminal { - color: #ce9178; + .log-entry.log-type-info .log-icon { + color: var(--vscode-charts-blue); } - .log-prefix.source-browser { - color: #569cd6; + .log-badge { + align-self: center; + justify-self: start; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.4px; + padding: 2px 6px; + border-radius: 5px; } - - .log-content { - flex: 1; - min-width: 0; - word-break: break-word; - line-height: 18px; - display: flex; - align-items: baseline; + .b-test { + color: var(--vscode-charts-green); + background: color-mix( + in srgb, + var(--vscode-charts-green) 14%, + transparent + ); + } + .b-runner { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + } + .b-browser { + color: var(--vscode-charts-blue); + background: color-mix( + in srgb, + var(--vscode-charts-blue) 14%, + transparent + ); } .log-message { @@ -112,18 +184,6 @@ export class DevtoolsConsoleLogs extends Element { word-break: break-word; } - .log-entry.log-type-error .log-message { - color: #f48771; - } - - .log-entry.log-type-warn .log-message { - color: #cd9731; - } - - .log-entry.log-type-info .log-message { - color: #75beff; - } - .empty-state { position: absolute; inset: 0; @@ -134,12 +194,10 @@ export class DevtoolsConsoleLogs extends Element { gap: 12px; color: var(--vscode-descriptionForeground); } - .empty-state-icon { font-size: 48px; opacity: 0.3; } - .empty-state-text { font-size: 14px; opacity: 0.6; @@ -150,6 +208,12 @@ export class DevtoolsConsoleLogs extends Element { @consume({ context: consoleLogContext, subscribe: true }) logs: ConsoleLogs[] | undefined = undefined + @state() + private searchText = '' + + @state() + private activeLevel: ConsoleLevelFilter = 'all' + get logCount(): number { return this.logs?.length || 0 } @@ -164,22 +228,34 @@ export class DevtoolsConsoleLogs extends Element { return `${elapsed.toFixed(1)}s` } - #formatArgs(args: unknown): string { - if (Array.isArray(args)) { - return args - .map((arg) => { - if (typeof arg === 'string') { - return arg - } - try { - return JSON.stringify(arg, null, 2) - } catch { - return String(arg) - } - }) - .join(' ') - } - return String(args) + #renderToolbar() { + return html` +
    + { + this.searchText = (e.target as HTMLInputElement).value + }} + /> +
    + ${LEVEL_FILTERS.map( + ({ key, label }) => html` + + ` + )} +
    +
    + ` } #renderEmptyState() { @@ -193,31 +269,17 @@ export class DevtoolsConsoleLogs extends Element { #renderLogEntry(log: ConsoleLogs) { const icon = LOG_ICONS[log.type] || LOG_ICONS.log - const sourceLabel = - log.source === 'test' - ? '[TEST]' - : log.source === 'terminal' - ? '[WDIO]' - : log.source === 'browser' - ? '[BROWSER]' - : '' - const sourceClass = log.source ? `source-${log.source}` : '' + const badge = log.source ? CONSOLE_SOURCE_BADGE[log.source] : undefined return html`
    - ${log.timestamp - ? html`
    - ${this.#formatElapsedTime(log.timestamp)} -
    ` - : nothing} -
    ${icon}
    -
    - ${sourceLabel - ? html`${sourceLabel}` - : nothing} - ${this.#formatArgs(log.args)} +
    + ${log.timestamp ? this.#formatElapsedTime(log.timestamp) : ''}
    +
    ${icon}
    + ${badge + ? html`${badge.label}` + : html``} + ${formatConsoleArgs(log.args)}
    ` } @@ -226,9 +288,19 @@ export class DevtoolsConsoleLogs extends Element { if (!this.logs || this.logs.length === 0) { return this.#renderEmptyState() } + const visible = filterConsoleLogs( + this.logs, + this.activeLevel, + this.searchText + ) return html` + ${this.#renderToolbar()}
    - ${this.logs.map((log) => this.#renderLogEntry(log))} + ${visible.length + ? visible.map((log) => this.#renderLogEntry(log)) + : html`
    + No logs match the current filter. +
    `}
    ` } diff --git a/packages/app/src/components/workbench/logs.ts b/packages/app/src/components/workbench/logs.ts index 674082ce..2c065283 100644 --- a/packages/app/src/components/workbench/logs.ts +++ b/packages/app/src/components/workbench/logs.ts @@ -1,11 +1,12 @@ import { Element } from '@core/element' -import { html, css } from 'lit' +import { html, css, nothing, type TemplateResult } from 'lit' import { customElement, property } from 'lit/decorators.js' import type { CommandLog } from '@wdio/devtools-shared' import type { CommandEndpoint } from '@wdio/protocols' -import './list.js' +import { commandCategory } from './actionItems/category.js' +import { formatDuration } from './actionItems/duration.js' const SOURCE_COMPONENT = 'wdio-devtools-logs' @customElement(SOURCE_COMPONENT) @@ -22,10 +23,122 @@ export class DevtoolsCommandLogs extends Element { ...Element.styles, css` :host { - display: block; + display: flex; + flex-direction: column; width: 100%; height: 100%; - min-height: 200px; + min-height: 0; + color: var(--vscode-foreground); + } + + .cmd-empty { + flex: 1; + display: grid; + place-items: center; + color: var(--vscode-descriptionForeground); + font-size: 13px; + } + + .cmd-head { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--vscode-panel-border); + } + .cat-dot { + width: 9px; + height: 9px; + border-radius: 3px; + flex: none; + } + .cat-navigation { + background: var(--vscode-charts-blue); + } + .cat-input { + background: var(--vscode-charts-purple); + } + .cat-assertion { + background: var(--vscode-charts-green); + } + .cat-query { + background: var(--vscode-charts-yellow); + } + .cat-other { + background: var(--vscode-descriptionForeground); + } + .cmd-name { + font-family: var(--vscode-editor-font-family); + font-weight: 700; + font-size: 14px; + } + .cmd-dur { + font-family: var(--vscode-editor-font-family); + font-size: 11px; + color: var(--vscode-editorLineNumber-foreground); + } + .cmd-ref { + margin-left: auto; + font-size: 12px; + color: var(--accent); + text-decoration: none; + } + .cmd-ref:hover { + text-decoration: underline; + } + + .cmd-body { + flex: 1; + overflow: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 16px; + } + .dsec > h4 { + font-size: 10.5px; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; + } + .cmd-desc { + font-size: 12.5px; + color: var(--vscode-descriptionForeground); + line-height: 1.65; + } + + .kv-card { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + overflow: hidden; + } + .kv { + display: grid; + grid-template-columns: minmax(80px, auto) 1fr; + gap: 14px; + padding: 9px 14px; + font-size: 12px; + border-top: 1px solid var(--vscode-panel-border); + } + .kv:first-child { + border-top: none; + } + .kv .k { + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family); + white-space: nowrap; + } + .kv .v { + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family); + word-break: break-all; + text-align: right; + } + .kv .v.empty { + color: var(--vscode-editorLineNumber-foreground); } ` ] @@ -67,75 +180,103 @@ export class DevtoolsCommandLogs extends Element { this.#commandDefinition = endpoints[command.command] this.command = command - window.dispatchEvent( - new CustomEvent('app-source-highlight', { - detail: this.command?.callSource - }) - ) + // Source line-tracking is dispatched by the Actions handler; here we only + // surface the command's detail in the Log tab. this.closest('wdio-devtools-tabs')?.activateTab('Log') }) } + #stringify(value: unknown): string { + if (typeof value === 'string') { + return value + } + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } + } + + #renderKvCard(rows: Array<[string, unknown]>): TemplateResult { + return html` +
    + ${rows.map(([key, value]) => { + const isEmpty = value === null || value === undefined + return html`
    + ${key} + ${isEmpty ? 'null' : this.#stringify(value)} +
    ` + })} +
    + ` + } + #renderParameters() { const args = this.command!.args || [] - const params = args.reduce( - (acc: Record, val: unknown, i: number) => { - const paramName = this.#commandDefinition?.parameters?.[i]?.name ?? i - acc[paramName] = val - return acc - }, - {} as Record - ) - return html`` + if (args.length === 0) { + return nothing + } + const rows = args.map((val, i): [string, unknown] => [ + String(this.#commandDefinition?.parameters?.[i]?.name ?? i), + val + ]) + return html` +
    +

    Parameters

    + ${this.#renderKvCard(rows)} +
    + ` } #renderResult() { const result = this.command!.result if (result === null || result === undefined) { - return '' + return nothing } - return html`` + const rows: Array<[string, unknown]> = + typeof result === 'object' ? Object.entries(result) : [['value', result]] + return html` +
    +

    Result

    + ${this.#renderKvCard(rows)} +
    + ` } render() { if (!this.command) { - return html` -
    - Please select a command to view details! -
    - ` + return html`
    + Select a command to view its details +
    ` } + const category = commandCategory(this.command.command) + const definition = this.#commandDefinition return html` -
    -

    ${this.command.command}

    - ${this.#commandDefinition && - html`Reference`} -
    - ${this.#commandDefinition && - html` - - - `} - ${this.#renderParameters()} ${this.#renderResult()} +
    + + ${this.command.command} + ${this.elapsedTime !== undefined + ? html`${formatDuration(this.elapsedTime)}` + : nothing} + ${definition + ? html`Reference ↗` + : nothing} +
    +
    + ${definition?.description + ? html`
    +

    Description

    +
    ${definition.description}
    +
    ` + : nothing} + ${this.#renderParameters()} ${this.#renderResult()} +
    ` } } diff --git a/packages/app/src/controller/constants.ts b/packages/app/src/controller/constants.ts index 84679f34..05168031 100644 --- a/packages/app/src/controller/constants.ts +++ b/packages/app/src/controller/constants.ts @@ -8,8 +8,18 @@ export const SIDEBAR_DEFAULT_WIDTH = 350 export const ACTIONS_DEFAULT_WIDTH = 360 export const BROWSER_HEIGHT_RATIO = 1.4 / 2.4 export const LOG_ICONS: Record = { - log: '📄', - info: 'ℹ️', - warn: '⚠️', - error: '❌' + log: '›', + info: 'ⓘ', + warn: '⚠', + error: '✕' +} + +/** Console-tab badge per log source: short label + style class. */ +export const CONSOLE_SOURCE_BADGE: Record< + NonNullable, + { label: string; class: string } +> = { + test: { label: 'TEST', class: 'b-test' }, + terminal: { label: 'RUNNER', class: 'b-runner' }, + browser: { label: 'PAGE', class: 'b-browser' } } diff --git a/packages/app/tests/console-filter.test.ts b/packages/app/tests/console-filter.test.ts new file mode 100644 index 00000000..382d7201 --- /dev/null +++ b/packages/app/tests/console-filter.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + filterConsoleLogs, + formatConsoleArgs, + stripAnsi +} from '../src/components/workbench/console-filter.js' + +const ESC = '' + +function log( + type: ConsoleLogs['type'], + args: unknown[], + source?: ConsoleLogs['source'] +): ConsoleLogs { + return { type, args, timestamp: 0, source } +} + +describe('stripAnsi', () => { + it('removes SGR color escape sequences', () => { + expect(stripAnsi(`${ESC}[90m2026-06-16${ESC}[39m INFO`)).toBe( + '2026-06-16 INFO' + ) + }) + + it('leaves plain text untouched', () => { + expect(stripAnsi('no codes here')).toBe('no codes here') + }) +}) + +describe('formatConsoleArgs', () => { + it('joins string args with a space', () => { + expect(formatConsoleArgs(['a', 'b'])).toBe('a b') + }) + + it('strips ANSI codes from logger output', () => { + expect(formatConsoleArgs([`${ESC}[31mError${ESC}[39m`])).toBe('Error') + }) + + it('pretty-prints non-string args', () => { + expect(formatConsoleArgs([{ x: 1 }])).toBe('{\n "x": 1\n}') + }) + + it('falls back to String() for non-arrays', () => { + expect(formatConsoleArgs(42)).toBe('42') + }) +}) + +describe('filterConsoleLogs', () => { + const logs = [ + log('log', ['hello world']), + log('warn', ['deprecated API']), + log('error', ['boom failed']), + log('info', ['connected']) + ] + + it('returns everything for level "all" and empty search', () => { + expect(filterConsoleLogs(logs, 'all', '')).toHaveLength(4) + }) + + it('filters by a single level', () => { + const errs = filterConsoleLogs(logs, 'error', '') + expect(errs).toHaveLength(1) + expect(errs[0].args).toEqual(['boom failed']) + }) + + it('treats a missing type as "log"', () => { + const untyped = [{ args: ['x'], timestamp: 0 } as unknown as ConsoleLogs] + expect(filterConsoleLogs(untyped, 'log', '')).toHaveLength(1) + }) + + it('matches search case-insensitively against the message', () => { + const r = filterConsoleLogs(logs, 'all', 'WORLD') + expect(r).toHaveLength(1) + expect(r[0].args).toEqual(['hello world']) + }) + + it('combines level and search (both must match)', () => { + expect(filterConsoleLogs(logs, 'warn', 'boom')).toHaveLength(0) + expect(filterConsoleLogs(logs, 'warn', 'deprecated')).toHaveLength(1) + }) + + it('ignores leading/trailing whitespace in the search', () => { + expect(filterConsoleLogs(logs, 'all', ' ')).toHaveLength(4) + }) +}) diff --git a/packages/app/tests/contextUpdates.test.ts b/packages/app/tests/contextUpdates.test.ts index 6b53bd80..641acd55 100644 --- a/packages/app/tests/contextUpdates.test.ts +++ b/packages/app/tests/contextUpdates.test.ts @@ -113,7 +113,11 @@ describe('mergeSessionMetadata', () => { it('merges a sessionId-less update into the current session', () => { const first = mergeSessionMetadata( { bySession: {} }, - meta({ sessionId: 's1', url: '/a', capabilities: { browserName: 'chrome' } }) + meta({ + sessionId: 's1', + url: '/a', + capabilities: { browserName: 'chrome' } + }) ) const next = mergeSessionMetadata(first, meta({ url: '/secure' })) // url updates, capabilities preserved (the overwrite regression) @@ -130,14 +134,20 @@ describe('mergeSessionMetadata', () => { { bySession: {} }, meta({ sessionId: 's1', url: '/a' }) ) - const second = mergeSessionMetadata(first, meta({ sessionId: 's2', url: '/b' })) + const second = mergeSessionMetadata( + first, + meta({ sessionId: 's2', url: '/b' }) + ) expect(second.bySession.s1).toEqual({ sessionId: 's1', url: '/a' }) expect(second.bySession.s2).toEqual({ sessionId: 's2', url: '/b' }) expect(second.active).toEqual({ sessionId: 's2', url: '/b' }) }) it('buffers under PENDING_SESSION_KEY then folds into the first session', () => { - const pending = mergeSessionMetadata({ bySession: {} }, meta({ url: '/early' })) + const pending = mergeSessionMetadata( + { bySession: {} }, + meta({ url: '/early' }) + ) expect(pending.bySession[PENDING_SESSION_KEY]).toEqual({ url: '/early' }) expect(pending.currentSessionId).toBeUndefined() @@ -161,7 +171,11 @@ describe('mergeSessionMetadata', () => { // session-start re-broadcast carries url: '' — must not wipe the real url const after = mergeSessionMetadata( withUrl, - meta({ sessionId: 's1', url: '', capabilities: { browserName: 'chrome' } }) + meta({ + sessionId: 's1', + url: '', + capabilities: { browserName: 'chrome' } + }) ) expect(after.bySession.s1.url).toBe('https://example.com') expect(after.bySession.s1.capabilities).toEqual({ browserName: 'chrome' }) @@ -182,7 +196,10 @@ describe('mergeSessionMetadata', () => { it('does not mutate the input map', () => { const state = { bySession: { s1: meta({ sessionId: 's1', url: '/a' }) } } - const next = mergeSessionMetadata(state, meta({ sessionId: 's2', url: '/b' })) + const next = mergeSessionMetadata( + state, + meta({ sessionId: 's2', url: '/b' }) + ) expect(next.bySession).not.toBe(state.bySession) expect(Object.keys(state.bySession)).toEqual(['s1']) }) From a2845bb61d7719efaee2ddbe7d7210f3a352b0b2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 13:26:33 +0530 Subject: [PATCH 15/22] feat: iframe url mapping --- .../src/components/browser/browser-chrome.ts | 36 +++++++++ .../app/src/components/browser/snapshot.ts | 50 +++++++------ .../components/browser/url-at-timestamp.ts | 44 +++++++++++ .../app/src/components/workbench/console.ts | 5 +- packages/app/tests/url-at-timestamp.test.ts | 75 +++++++++++++++++++ 5 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/components/browser/browser-chrome.ts create mode 100644 packages/app/src/components/browser/url-at-timestamp.ts create mode 100644 packages/app/tests/url-at-timestamp.test.ts diff --git a/packages/app/src/components/browser/browser-chrome.ts b/packages/app/src/components/browser/browser-chrome.ts new file mode 100644 index 00000000..0aa0a4da --- /dev/null +++ b/packages/app/src/components/browser/browser-chrome.ts @@ -0,0 +1,36 @@ +import type { nothing } from 'lit' +import { html, type TemplateResult } from 'lit' + +import '~icons/mdi/world.js' +import '~icons/mdi/lock.js' + +/** The browser-frame chrome: traffic-light dots, address bar (with a lock for + * https), and a slot for the Snapshot/Screencast view toggle. Extracted from + * the snapshot component so that file stays focused on capture/replay. */ +export function renderBrowserChrome( + displayUrl: string | undefined, + viewToggle: TemplateResult | typeof nothing +): TemplateResult { + return html` +
    +
    +
    +
    +
    + ${displayUrl?.startsWith('https') + ? html`` + : html``} + ${displayUrl} +
    + ${viewToggle} +
    + ` +} diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 9b912f58..16d5a4e9 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -2,6 +2,8 @@ import { Element } from '@core/element' import { html, nothing } from 'lit' import { consume } from '@lit/context' import { snapshotStyles } from './snapshot-styles.js' +import { renderBrowserChrome } from './browser-chrome.js' +import { commandPageUrl } from './url-at-timestamp.js' import { type ComponentChildren, h, render, type VNode } from 'preact' import { customElement, query } from 'lit/decorators.js' @@ -11,12 +13,11 @@ import type { CommandLog } from '@wdio/devtools-shared' import { mutationContext, metadataContext, + metadataBySessionContext, commandContext } from '../../controller/context.js' -import type { Metadata } from '@wdio/devtools-shared' +import type { Metadata, MetadataBySession } from '@wdio/devtools-shared' -import '~icons/mdi/world.js' -import '~icons/mdi/lock.js' import '../placeholder.js' import './screencast-player.js' @@ -98,6 +99,9 @@ export class DevtoolsBrowser extends Element { @consume({ context: metadataContext, subscribe: true }) metadata: Metadata | undefined = undefined + @consume({ context: metadataBySessionContext, subscribe: true }) + metadataBySession: MetadataBySession | undefined = undefined + @consume({ context: mutationContext, subscribe: true }) mutations: TraceMutation[] = [] @@ -259,8 +263,27 @@ export class DevtoolsBrowser extends Element { return { startTime: v?.startTime, duration: v?.duration } } + /** URL for the address bar: in video mode the selected recording's page URL + * (looked up by its sessionId), else the snapshot's resolved URL. */ + get #displayUrl(): string | undefined { + if (this.#viewMode === 'video') { + const sessionId = this.#videos[this.#activeVideoIdx]?.sessionId + const sessionUrl = sessionId + ? this.metadataBySession?.[sessionId]?.url + : undefined + return sessionUrl ?? this.#activeUrl ?? this.metadata?.url + } + return this.#activeUrl + } + async #renderCommandScreenshot(command?: CommandLog) { this.#screenshotData = command?.screenshot ?? null + // Follow the selected command's page in the address bar — commands carry no + // URL, so resolve it from the navigation active at the command's time. + if (command) { + this.#activeUrl = + commandPageUrl(command, this.mutations ?? []) ?? this.#activeUrl + } // Switch to snapshot mode so the command screenshot is visible instead of the video. this.#viewMode = 'snapshot' this.requestUpdate() @@ -579,26 +602,7 @@ export class DevtoolsBrowser extends Element {
    -
    -
    -
    -
    -
    - ${this.#activeUrl?.startsWith('https') - ? html`` - : html``} - ${this.#activeUrl} -
    - ${this.#renderViewToggle()} -
    + ${renderBrowserChrome(this.#displayUrl, this.#renderViewToggle())} ${this.#renderViewport(hasMutations)}
    ` diff --git a/packages/app/src/components/browser/url-at-timestamp.ts b/packages/app/src/components/browser/url-at-timestamp.ts new file mode 100644 index 00000000..2a583db5 --- /dev/null +++ b/packages/app/src/components/browser/url-at-timestamp.ts @@ -0,0 +1,44 @@ +import type { CommandLog, TraceMutation } from '@wdio/devtools-shared' + +import { commandCategory } from '../workbench/actionItems/category.js' + +const ABSOLUTE_URL = /^https?:\/\// + +/** The page URL captured at or before `timestamp` — the most recent navigation + * in the mutation stream up to that point. Commands carry no URL of their own, + * so the address bar uses this to follow command selection in snapshot mode. */ +export function urlAtTimestamp( + mutations: TraceMutation[], + timestamp: number +): string | undefined { + let url: string | undefined + let best = -Infinity + for (const mutation of mutations) { + if ( + mutation.url && + mutation.timestamp <= timestamp && + mutation.timestamp >= best + ) { + best = mutation.timestamp + url = mutation.url + } + } + return url +} + +/** The page URL a command's screenshot shows. A navigation command changes the + * page — the new URL only appears in the mutation stream *after* the command's + * timestamp, so use its destination argument; every other command runs on the + * page already active at its time. */ +export function commandPageUrl( + command: CommandLog, + mutations: TraceMutation[] +): string | undefined { + if (commandCategory(command.command) === 'navigation') { + const target = command.args?.[0] + if (typeof target === 'string' && ABSOLUTE_URL.test(target)) { + return target + } + } + return urlAtTimestamp(mutations, command.timestamp) +} diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index 12cf5424..572baf80 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -101,7 +101,7 @@ export class DevtoolsConsoleLogs extends Element { .log-entry { display: grid; grid-template-columns: 46px 16px auto 1fr; - align-items: baseline; + align-items: start; gap: 10px; padding: 4px 14px; border-bottom: 1px solid var(--vscode-panel-border); @@ -134,6 +134,7 @@ export class DevtoolsConsoleLogs extends Element { } .log-icon { text-align: center; + line-height: 1.55; color: var(--vscode-descriptionForeground); } .log-entry.log-type-error .log-icon, @@ -149,9 +150,9 @@ export class DevtoolsConsoleLogs extends Element { } .log-badge { - align-self: center; justify-self: start; font-size: 9.5px; + line-height: 1.55; font-weight: 700; letter-spacing: 0.4px; padding: 2px 6px; diff --git a/packages/app/tests/url-at-timestamp.test.ts b/packages/app/tests/url-at-timestamp.test.ts new file mode 100644 index 00000000..28169996 --- /dev/null +++ b/packages/app/tests/url-at-timestamp.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import type { CommandLog, TraceMutation } from '@wdio/devtools-shared' +import { + urlAtTimestamp, + commandPageUrl +} from '../src/components/browser/url-at-timestamp.js' + +function nav(timestamp: number, url: string): TraceMutation { + return { + type: 'childList', + addedNodes: [], + removedNodes: [], + timestamp, + url + } +} + +function plain(timestamp: number): TraceMutation { + return { type: 'attributes', addedNodes: [], removedNodes: [], timestamp } +} + +describe('urlAtTimestamp', () => { + const mutations = [ + nav(100, 'https://a.com'), + plain(150), + nav(200, 'https://b.com'), + plain(250) + ] + + it('returns the navigation active at the given time', () => { + expect(urlAtTimestamp(mutations, 175)).toBe('https://a.com') + expect(urlAtTimestamp(mutations, 230)).toBe('https://b.com') + }) + + it('includes a navigation that lands exactly on the timestamp', () => { + expect(urlAtTimestamp(mutations, 200)).toBe('https://b.com') + }) + + it('returns undefined before any navigation', () => { + expect(urlAtTimestamp(mutations, 50)).toBeUndefined() + }) + + it('ignores mutations without a url', () => { + expect(urlAtTimestamp([plain(100), plain(200)], 300)).toBeUndefined() + }) + + it('picks the latest navigation regardless of array order', () => { + const unordered = [nav(200, 'https://b.com'), nav(100, 'https://a.com')] + expect(urlAtTimestamp(unordered, 250)).toBe('https://b.com') + }) +}) + +function cmd(command: string, timestamp: number, args: unknown[]): CommandLog { + return { command, args, timestamp } +} + +describe('commandPageUrl', () => { + // The destination nav mutation lands *after* the navigation command's time. + const mutations = [nav(100, 'https://a.com'), nav(300, 'https://b.com')] + + it('uses a navigation command argument (mutation lands after its time)', () => { + const urlCmd = cmd('url', 200, ['https://b.com']) + expect(commandPageUrl(urlCmd, mutations)).toBe('https://b.com') + }) + + it('falls back to the active page for non-navigation commands', () => { + const getText = cmd('getText', 200, ['#flash']) + expect(commandPageUrl(getText, mutations)).toBe('https://a.com') + }) + + it('ignores non-URL navigation args (e.g. back/switchWindow)', () => { + const back = cmd('back', 200, []) + expect(commandPageUrl(back, mutations)).toBe('https://a.com') + }) +}) From 920fe93debd534363648c1018092d6f68b611757 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 13:57:16 +0530 Subject: [PATCH 16/22] fix: select one action per click and resolve its page URL from commands --- .../app/src/components/browser/snapshot.ts | 3 +- .../components/browser/url-at-timestamp.ts | 38 ++++++++++++++----- .../app/src/components/workbench/actions.ts | 21 +++++----- packages/app/tests/url-at-timestamp.test.ts | 32 +++++++++++----- 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts index 16d5a4e9..16fae044 100644 --- a/packages/app/src/components/browser/snapshot.ts +++ b/packages/app/src/components/browser/snapshot.ts @@ -282,7 +282,8 @@ export class DevtoolsBrowser extends Element { // URL, so resolve it from the navigation active at the command's time. if (command) { this.#activeUrl = - commandPageUrl(command, this.mutations ?? []) ?? this.#activeUrl + commandPageUrl(command, this.commands ?? [], this.mutations ?? []) ?? + this.#activeUrl } // Switch to snapshot mode so the command screenshot is visible instead of the video. this.#viewMode = 'snapshot' diff --git a/packages/app/src/components/browser/url-at-timestamp.ts b/packages/app/src/components/browser/url-at-timestamp.ts index 2a583db5..8f1302a2 100644 --- a/packages/app/src/components/browser/url-at-timestamp.ts +++ b/packages/app/src/components/browser/url-at-timestamp.ts @@ -26,19 +26,39 @@ export function urlAtTimestamp( return url } -/** The page URL a command's screenshot shows. A navigation command changes the - * page — the new URL only appears in the mutation stream *after* the command's - * timestamp, so use its destination argument; every other command runs on the - * page already active at its time. */ +/** First argument of a navigation command when it's an absolute URL — `url`, + * `navigateTo` etc. carry their destination there. */ +function navigationTarget(command: CommandLog): string | undefined { + if (commandCategory(command.command) !== 'navigation') { + return undefined + } + const target = command.args?.[0] + return typeof target === 'string' && ABSOLUTE_URL.test(target) + ? target + : undefined +} + +/** The page URL a command's screenshot shows: the destination of the most + * recent navigation command at or before it (the command itself when it + * navigates). The command stream is authoritative — unlike the DOM mutation + * stream it captures every navigation even on pages that block DOM capture — + * so mutations are only a fallback for traces without command navigations. */ export function commandPageUrl( command: CommandLog, + commands: CommandLog[], mutations: TraceMutation[] ): string | undefined { - if (commandCategory(command.command) === 'navigation') { - const target = command.args?.[0] - if (typeof target === 'string' && ABSOLUTE_URL.test(target)) { - return target + let url: string | undefined + let best = -Infinity + for (const candidate of commands) { + if (candidate.timestamp > command.timestamp || candidate.timestamp < best) { + continue + } + const target = navigationTarget(candidate) + if (target) { + best = candidate.timestamp + url = target } } - return urlAtTimestamp(mutations, command.timestamp) + return url ?? urlAtTimestamp(mutations, command.timestamp) } diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 76e6d369..86f4e856 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -56,13 +56,16 @@ export class DevtoolsActions extends Element { @consume({ context: commandContext, subscribe: true }) commands: CommandLog[] = [] + // The selected timeline row, tracked by object reference — timestamps aren't + // unique (commands logged in the same millisecond would all match), so + // reference identity is what highlights exactly one row. @state() - private activeTimestamp?: number + private activeEntry?: TimelineEntry #onShowCommand = (event: Event) => { const command = (event as CustomEvent<{ command?: CommandLog }>).detail ?.command - this.activeTimestamp = command?.timestamp + this.activeEntry = command // Follow the call site in the Source editor passively — the Log tab is what // surfaces on a command click, so stealing focus to Source would flash. if (command?.callSource) { @@ -75,9 +78,7 @@ export class DevtoolsActions extends Element { } #onSelectMutation = (event: Event) => { - this.activeTimestamp = ( - event as CustomEvent - ).detail?.timestamp + this.activeEntry = (event as CustomEvent).detail } // Screencast playback drives the highlight to the action at the current frame. @@ -90,11 +91,11 @@ export class DevtoolsActions extends Element { entries.map((entry) => entry.timestamp), time ) - if (timestamp === this.activeTimestamp) { + const active = entries.find((entry) => entry.timestamp === timestamp) + if (active === this.activeEntry) { return } - this.activeTimestamp = timestamp - const active = entries.find((entry) => entry.timestamp === timestamp) + this.activeEntry = active if (active && 'command' in active && active.callSource) { window.dispatchEvent( new CustomEvent('app-source-track', { @@ -138,7 +139,7 @@ export class DevtoolsActions extends Element { // Keep the action that's playing in view as the screencast scrubs. updated(changed: Map): void { - if (changed.has('activeTimestamp') && this.activeTimestamp !== undefined) { + if (changed.has('activeEntry') && this.activeEntry !== undefined) { this.renderRoot .querySelector('[active]') ?.scrollIntoView({ block: 'nearest' }) @@ -157,7 +158,7 @@ export class DevtoolsActions extends Element { const rows = entries.map((entry, index) => { const elapsedTime = entry.timestamp - baselineTimestamp const duration = durations[index] - const active = entry.timestamp === this.activeTimestamp + const active = entry === this.activeEntry if ('command' in entry) { return html` diff --git a/packages/app/tests/url-at-timestamp.test.ts b/packages/app/tests/url-at-timestamp.test.ts index 28169996..ca67fca6 100644 --- a/packages/app/tests/url-at-timestamp.test.ts +++ b/packages/app/tests/url-at-timestamp.test.ts @@ -55,21 +55,35 @@ function cmd(command: string, timestamp: number, args: unknown[]): CommandLog { } describe('commandPageUrl', () => { - // The destination nav mutation lands *after* the navigation command's time. - const mutations = [nav(100, 'https://a.com'), nav(300, 'https://b.com')] + const urlA = cmd('url', 100, ['https://a.com']) + const urlB = cmd('url', 300, ['https://b.com']) + const typeOnB = cmd('setValue', 400, ['#q', 'hello']) + const commands = [urlA, urlB, typeOnB] - it('uses a navigation command argument (mutation lands after its time)', () => { - const urlCmd = cmd('url', 200, ['https://b.com']) - expect(commandPageUrl(urlCmd, mutations)).toBe('https://b.com') + it('a navigation command resolves to its own destination', () => { + expect(commandPageUrl(urlB, commands, [])).toBe('https://b.com') }) - it('falls back to the active page for non-navigation commands', () => { + it('a later command resolves to the most recent navigation before it', () => { + // setValue ran on page B even when B was never captured as a mutation + expect(commandPageUrl(typeOnB, commands, [])).toBe('https://b.com') + }) + + it('a command before any later navigation keeps the earlier page', () => { + const typeOnA = cmd('setValue', 200, ['#q', 'hi']) + expect(commandPageUrl(typeOnA, [urlA, urlB, typeOnA], [])).toBe( + 'https://a.com' + ) + }) + + it('falls back to the mutation stream when no navigation command exists', () => { const getText = cmd('getText', 200, ['#flash']) - expect(commandPageUrl(getText, mutations)).toBe('https://a.com') + const mutations = [nav(100, 'https://a.com')] + expect(commandPageUrl(getText, [getText], mutations)).toBe('https://a.com') }) it('ignores non-URL navigation args (e.g. back/switchWindow)', () => { - const back = cmd('back', 200, []) - expect(commandPageUrl(back, mutations)).toBe('https://a.com') + const back = cmd('back', 500, []) + expect(commandPageUrl(back, [urlB, back], [])).toBe('https://b.com') }) }) From bdcf439a7dc84b70ebc128965c771ada9596f2e0 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 14:20:36 +0530 Subject: [PATCH 17/22] refactor: move console level filters to the filter module and drop dead code --- .../components/workbench/console-filter.ts | 12 ++ .../app/src/components/workbench/console.ts | 16 +- packages/app/src/components/workbench/list.ts | 174 ------------------ .../app/src/components/workbench/metadata.ts | 21 +-- 4 files changed, 20 insertions(+), 203 deletions(-) delete mode 100644 packages/app/src/components/workbench/list.ts diff --git a/packages/app/src/components/workbench/console-filter.ts b/packages/app/src/components/workbench/console-filter.ts index 9dcf6378..2a325ef0 100644 --- a/packages/app/src/components/workbench/console-filter.ts +++ b/packages/app/src/components/workbench/console-filter.ts @@ -6,6 +6,18 @@ /** Level filter options — `all` plus one per captured log type. */ export type ConsoleLevelFilter = 'all' | 'error' | 'warn' | 'info' | 'log' +/** Ordered level filters for the Console toolbar: filter key + display label. */ +export const CONSOLE_LEVEL_FILTERS: ReadonlyArray<{ + key: ConsoleLevelFilter + label: string +}> = [ + { key: 'all', label: 'All' }, + { key: 'error', label: 'Errors' }, + { key: 'warn', label: 'Warnings' }, + { key: 'info', label: 'Info' }, + { key: 'log', label: 'Logs' } +] + // SGR escape sequences (e.g. ``) from the WDIO terminal logger render // as stray `[31m` once the invisible ESC is dropped — strip them for display. const ANSI_SGR_RE = /\[[0-9;]*m/g diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index 572baf80..ae986562 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -8,18 +8,10 @@ import { LOG_ICONS, CONSOLE_SOURCE_BADGE } from '../../controller/constants.js' import { filterConsoleLogs, formatConsoleArgs, + CONSOLE_LEVEL_FILTERS, type ConsoleLevelFilter } from './console-filter.js' -const LEVEL_FILTERS: ReadonlyArray<{ key: ConsoleLevelFilter; label: string }> = - [ - { key: 'all', label: 'All' }, - { key: 'error', label: 'Errors' }, - { key: 'warn', label: 'Warnings' }, - { key: 'info', label: 'Info' }, - { key: 'log', label: 'Logs' } - ] - const SOURCE_COMPONENT = 'wdio-devtools-console-logs' @customElement(SOURCE_COMPONENT) export class DevtoolsConsoleLogs extends Element { @@ -215,10 +207,6 @@ export class DevtoolsConsoleLogs extends Element { @state() private activeLevel: ConsoleLevelFilter = 'all' - get logCount(): number { - return this.logs?.length || 0 - } - #startTime?: number #formatElapsedTime(timestamp: number): string { @@ -242,7 +230,7 @@ export class DevtoolsConsoleLogs extends Element { }} />
    - ${LEVEL_FILTERS.map( + ${CONSOLE_LEVEL_FILTERS.map( ({ key, label }) => html` - ` - } - - #unpackEntry( - entry: unknown, - isArrayList: boolean - ): { key: string | undefined; val: unknown } { - const isKeyValueTuple = (v: unknown): v is [string, unknown] => - Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' - if (isArrayList) { - if (isKeyValueTuple(entry)) { - return { key: entry[0], val: entry[1] } - } - return { key: undefined, val: entry } - } - const tuple = entry as [string, unknown] - return { key: tuple[0], val: tuple[1] } - } - - #renderRow(entry: unknown, isArrayList: boolean) { - const { key, val } = this.#unpackEntry(entry, isArrayList) - const stringForMeasure = - val && typeof val === 'object' - ? JSON.stringify(val, null, 2) - : String(val) - const isMultiline = - /\n/.test(stringForMeasure) || - stringForMeasure.length > 40 || - (val && typeof val === 'object') - const baseCls = 'row px-2 py-1 border-b-[1px] border-b-panelBorder' - const colCls = isMultiline ? 'basis-full w-full' : 'basis-1/2' - const collapsedCls = this.isCollapsed ? 'collapse' : 'max-h-[500px]' - if (key === undefined) { - return html` -
    - ${this.#renderMetadataProp(val)} -
    - ` - } - return html` -
    ${key}
    -
    - ${this.#renderMetadataProp(val)} -
    - ` - } - - render() { - const list = this.list ?? {} - const isArrayList = Array.isArray(list) - if (list === null) { - return null - } - if (isArrayList && (list as unknown[]).length === 0) { - return null - } - if ( - !isArrayList && - Object.keys(list as Record).length === 0 - ) { - return null - } - const entries: unknown[] = isArrayList - ? (this.list as unknown[]) - : Object.entries(this.list as Record) - return html` -
    - ${this.#renderSectionHeader(this.label)} -
    - ${entries.map((entry) => this.#renderRow(entry, isArrayList))} -
    -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [SOURCE_COMPONENT]: DevtoolsList - } -} diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts index 4540c417..0bae239d 100644 --- a/packages/app/src/components/workbench/metadata.ts +++ b/packages/app/src/components/workbench/metadata.ts @@ -149,7 +149,7 @@ export class DevtoolsMetadata extends Element { ` ] - #buildSessionInfo(m: MetadataShape): Record { + #buildSessionInfo(m: Metadata): Record { const sessionInfo: Record = {} if (m.sessionId) { sessionInfo['Session ID'] = m.sessionId @@ -205,8 +205,10 @@ export class DevtoolsMetadata extends Element { ` } - #renderSection(label: string, data: Record | undefined) { - const entries = Object.entries(data ?? {}) + #renderSection(label: string, data: unknown) { + // Metadata's capability/option bags are typed `unknown` upstream; narrow to + // a record here so the section can iterate their key/value pairs. + const entries = Object.entries((data ?? {}) as Record) if (entries.length === 0) { return nothing } @@ -295,7 +297,7 @@ export class DevtoolsMetadata extends Element { render() { const sessions = this.#sessions() - const active = this.#activeMetadata(sessions) as MetadataShape | undefined + const active = this.#activeMetadata(sessions) if (!active) { return html`` } @@ -314,17 +316,6 @@ export class DevtoolsMetadata extends Element { } } -interface MetadataShape { - sessionId?: string - testEnv?: string - host?: string - modulePath?: string - url?: string - capabilities?: Record - desiredCapabilities?: Record - options?: Record -} - declare global { interface HTMLElementTagNameMap { [SOURCE_COMPONENT]: DevtoolsMetadata From 9736cca26057535caf98b923eadbc4b36befdbdc Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 17:18:52 +0530 Subject: [PATCH 18/22] fix: attribute preserved baseline commands by source location --- .../app/src/components/workbench/compare.ts | 77 ++-- .../components/workbench/compare/styles.ts | 351 +++++++++++------- .../src/baseline/command-attribution.ts | 146 ++++++++ packages/backend/src/baselineStore.ts | 95 ++++- packages/backend/src/index.ts | 9 +- packages/backend/tests/baselineStore.test.ts | 150 ++++++++ packages/core/src/session-capturer.ts | 14 +- .../nightwatch-devtools/src/session-init.ts | 10 +- packages/nightwatch-devtools/src/session.ts | 6 +- packages/service/src/index.ts | 1 + packages/service/src/session.ts | 46 ++- 11 files changed, 728 insertions(+), 177 deletions(-) create mode 100644 packages/backend/src/baseline/command-attribution.ts diff --git a/packages/app/src/components/workbench/compare.ts b/packages/app/src/components/workbench/compare.ts index 84e4c91e..ac6d8640 100644 --- a/packages/app/src/components/workbench/compare.ts +++ b/packages/app/src/components/workbench/compare.ts @@ -224,38 +224,45 @@ export class DevtoolsCompare extends Element { return html`
    + Baseline · ${baseline.test.state || 'unknown'} · ${baselineCount} commands - - Latest · ${latestCount} commands - + + + Latest · ${latestCount} commands + + ${baseline.scope === 'suite' ? 'suite scope' : 'test scope'} - - - - ${this.#renderPopoutButton()} +
    + + + + ${this.#renderPopoutButton()} +
    ` } @@ -286,12 +293,16 @@ export class DevtoolsCompare extends Element {
    ${errorMessage}
    ` : nothing} -
    -
    ${this.swapped ? 'Latest' : 'Baseline'}
    -
    ${this.swapped ? 'Baseline' : 'Latest'}
    - ${visiblePairs.map((pair) => - this.#renderPair(pair, leftCommands, rightCommands, firstDivergent) - )} +
    +
    +
    ${this.swapped ? 'Latest' : 'Baseline'}
    +
    ${this.swapped ? 'Baseline' : 'Latest'}
    +
    +
    + ${visiblePairs.map((pair) => + this.#renderPair(pair, leftCommands, rightCommands, firstDivergent) + )} +
    ` } diff --git a/packages/app/src/components/workbench/compare/styles.ts b/packages/app/src/components/workbench/compare/styles.ts index 3b8cadc8..34b665e0 100644 --- a/packages/app/src/components/workbench/compare/styles.ts +++ b/packages/app/src/components/workbench/compare/styles.ts @@ -14,192 +14,275 @@ export const compareStyles = css` background-color: var(--vscode-editor-background, #1e1e1e); color: var(--vscode-foreground, #cccccc); } - .compare-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0; - flex: 1 1 auto; - min-height: 0; - overflow: auto; - /* Stack rows from the top so they don't stretch to fill the grid. */ - align-content: start; - grid-auto-rows: min-content; - } - .step-row { - display: contents; + + /* ── Toolbar ── */ + .topbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 14px; + border-bottom: 1px solid var(--vscode-panel-border); } - .step-cell { - padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + .pill { + display: inline-flex; + align-items: center; + gap: 7px; font-family: var(--vscode-editor-font-family, monospace); - font-size: 0.85em; - cursor: pointer; + font-size: 11.5px; + padding: 4px 11px; + border-radius: 999px; + background: var(--vscode-editorWidget-background); + color: var(--vscode-descriptionForeground); + border: 1px solid var(--vscode-panel-border); + } + .pill .dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex: none; + background: var(--vscode-editorLineNumber-foreground); + } + .pill.passed .dot { + background: var(--vscode-charts-green); } - .step-cell.divergent { - background: rgba(255, 90, 90, 0.08); + .pill.failed { + color: var(--vscode-charts-red); + border-color: color-mix(in srgb, var(--vscode-charts-red) 35%, transparent); } - .step-cell.divergent.first { - background: rgba(255, 90, 90, 0.18); - border-left: 3px solid var(--vscode-charts-red, #f48771); + .pill.failed .dot { + background: var(--vscode-charts-red); } - .marker { - margin-left: 0.35rem; - font-size: 0.85em; + .swap-ico { + color: var(--vscode-editorLineNumber-foreground); } - .marker.result { - color: var(--vscode-charts-orange, #d19a66); + .scope { + font-size: 11px; + color: var(--vscode-editorLineNumber-foreground); } - .marker.error { - color: var(--vscode-charts-red, #f48771); + .actions-group { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; } - .marker.command { - color: var(--vscode-charts-red, #f48771); + .toggle-label { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 11.5px; + color: var(--vscode-descriptionForeground); } - .marker.ok { - color: var(--vscode-charts-green, #73c373); + button.action { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + color: var(--vscode-descriptionForeground); + font-size: 11.5px; + padding: 5px 12px; + border-radius: 7px; + cursor: pointer; + font-family: inherit; } - .marker.info { - color: var(--vscode-descriptionForeground, #999); - opacity: 0.7; + button.action:hover { + color: var(--vscode-foreground); + background: var(--vscode-list-hoverBackground); } + button.action.icon-only { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 8px; + } + button.action.icon-only svg { + width: 1em; + height: 1em; + } + + /* ── Error banner ── */ .error-banner { - margin: 0.5rem 0.75rem; - padding: 0.5rem 0.75rem; - background: rgba(244, 135, 113, 0.12); - border-left: 3px solid var(--vscode-charts-red, #f48771); - border-radius: 3px; - font-size: 0.85em; + flex: none; + margin: 12px 14px 0; + padding: 10px 14px; + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-charts-red) 9%, transparent); + border: 1px solid + color-mix(in srgb, var(--vscode-charts-red) 25%, transparent); } .error-banner-title { - font-weight: 600; - margin-bottom: 0.25rem; - opacity: 0.85; - font-family: inherit; + font-size: 10px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 700; + color: var(--vscode-charts-red); + margin-bottom: 5px; } /* Pre-wrap only on the message body so template indentation doesn't render. */ .error-banner-message { font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + color: var(--vscode-foreground); white-space: pre-wrap; word-break: break-word; margin: 0; } - .step-cell.missing { - opacity: 0.35; - font-style: italic; + + /* ── Diff body ── */ + .cmp-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 12px 14px; + } + .cmp-colhead { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + position: sticky; + top: 0; + z-index: 2; + margin-bottom: 6px; + padding: 2px 0 8px; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border); + } + .col-header { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + color: var(--vscode-foreground); + } + .cmp-rows { + display: flex; + flex-direction: column; + gap: 4px; + } + .step-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .step-cell { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: 8px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12.5px; + color: var(--vscode-foreground); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); + cursor: pointer; + } + .step-cell code { + font-family: inherit; } .step-cell:hover { - background: var( - --vscode-toolbar-hoverBackground, - rgba(255, 255, 255, 0.06) - ); + background: var(--vscode-list-hoverBackground); + } + .step-cell.divergent { + background: color-mix(in srgb, var(--vscode-charts-red) 9%, transparent); + border-color: color-mix(in srgb, var(--vscode-charts-red) 30%, transparent); + box-shadow: inset 3px 0 0 var(--vscode-charts-red); + } + .step-cell.divergent.first { + background: color-mix(in srgb, var(--vscode-charts-red) 16%, transparent); } .step-cell.expanded { - background: rgba(80, 160, 255, 0.06); + outline: 1px solid var(--accent); } - .pill { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.1rem 0.5rem; - border-radius: 4px; - font-size: 0.85em; - background: var(--vscode-badge-background, #2a2a2a); + .step-cell.missing { + justify-content: center; + color: var(--vscode-editorLineNumber-foreground); + background: transparent; + border-style: dashed; + cursor: default; + font-style: italic; } - .pill.failed { - background: rgba(244, 135, 113, 0.2); - color: var(--vscode-charts-red, #f48771); + + /* ── Per-cell status markers ── */ + .marker { + margin-left: auto; + font-family: var(--vscode-font-family, sans-serif); + font-size: 10px; + padding: 1px 7px; + border-radius: 5px; } - .pill.passed { - background: rgba(115, 195, 115, 0.2); - color: var(--vscode-charts-green, #73c373); + .marker.ok { + padding: 0; + background: transparent; + font-size: 12px; + color: var(--vscode-charts-green); } - .topbar { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - flex: 0 0 auto; + .marker.command, + .marker.error { + color: var(--vscode-charts-red); + background: color-mix(in srgb, var(--vscode-charts-red) 16%, transparent); } - .col-header { - position: sticky; - top: 0; - background: var(--vscode-editor-background, #1e1e1e); - z-index: 1; - padding: 0.5rem; - font-weight: 600; - font-size: 0.85em; - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); + .marker.result { + color: var(--vscode-charts-yellow); + background: color-mix( + in srgb, + var(--vscode-charts-yellow) 16%, + transparent + ); } + .marker.info { + color: var(--vscode-charts-yellow); + background: color-mix( + in srgb, + var(--vscode-charts-yellow) 16%, + transparent + ); + } + + /* ── Expanded row detail ── */ .detail-panel { - grid-column: span 2; - background: var(--vscode-editor-background, #1e1e1e); - border-bottom: 1px solid var(--vscode-panel-border, #2a2a2a); - padding: 0.5rem; + grid-column: 1 / -1; + margin-top: 4px; + padding: 10px 12px; + border-radius: 8px; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-panel-border); } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 0.75rem; + gap: 14px; } .detail-block { - font-size: 0.85em; + font-size: 12px; } .detail-block h4 { - font-size: 0.85em; - margin: 0 0 0.25rem; - opacity: 0.7; - font-weight: 600; + font-size: 10px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 700; + margin: 0 0 6px; + color: var(--vscode-editorLineNumber-foreground); } .detail-block pre { margin: 0; + padding: 8px 10px; + border-radius: 6px; + font-size: 11px; white-space: pre-wrap; word-break: break-word; - font-size: 0.85em; - background: rgba(255, 255, 255, 0.03); - padding: 0.25rem 0.4rem; - border-radius: 3px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); } + .empty-state { flex: 1; display: flex; align-items: center; justify-content: center; - color: var(--vscode-descriptionForeground, #888); - font-size: 0.9em; - text-align: center; padding: 1rem; - } - .toggle-label { - display: inline-flex; - align-items: center; - gap: 0.35rem; - cursor: pointer; - font-size: 0.85em; - } - button.action { - background: transparent; - border: 1px solid var(--vscode-panel-border, #2a2a2a); - color: inherit; - padding: 0.2rem 0.5rem; - border-radius: 3px; - cursor: pointer; - font-size: 0.85em; - } - button.action:hover { - background: var( - --vscode-toolbar-hoverBackground, - rgba(255, 255, 255, 0.06) - ); - } - button.action.icon-only { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem 0.4rem; - } - button.action.icon-only svg { - width: 1em; - height: 1em; + text-align: center; + font-size: 0.9em; + color: var(--vscode-descriptionForeground, #888); } ` diff --git a/packages/backend/src/baseline/command-attribution.ts b/packages/backend/src/baseline/command-attribution.ts new file mode 100644 index 00000000..f7da7b76 --- /dev/null +++ b/packages/backend/src/baseline/command-attribution.ts @@ -0,0 +1,146 @@ +/** + * Attribute captured commands to a test/suite node for Preserve & Rerun. + * + * Adapters can't always tag a command with the right `testUid` — Nightwatch's + * before/after hooks fire once per spec file, so every test after the first in + * a multi-test file inherits the first test's uid. Source location is reliable + * instead: a command's call site (`callSource` line) sits inside exactly one + * `it()` block. Commands issued indirectly (e.g. an assertion's internal + * `getText`) have no resolvable call site and fall back to the node's time + * window, which keeps them with the test they ran in. + * + * Pure functions over the accumulator's node map + command list, so the + * attribution can be unit-tested without the store. + */ +import type { CommandLogLike, TimeWindowNode } from './types.js' + +type SourceRange = { file: string; start: number; end: number } +type TimeWindow = { start: number; end: number } +type NodeMap = Map + +/** Split a `callSource` (`file:line` or `file:line:col`) into file + line. */ +export function lineOf( + callSource?: string +): { file: string; line: number } | undefined { + if (!callSource) { + return undefined + } + const m = callSource.match(/:(\d+)(?::\d+)?$/) + if (!m || m.index === undefined) { + return undefined + } + return { file: callSource.slice(0, m.index), line: Number(m[1]) } +} + +/** Sorted declaration lines of every test node in `file` — bounds each test's + * source range at the next test's line. */ +function testLinesInFile(nodes: NodeMap, file: string): number[] { + const lines = new Set() + for (const n of nodes.values()) { + if (n.kind !== 'test') { + continue + } + const loc = lineOf(n.callSource) + if (loc && loc.file === file) { + lines.add(loc.line) + } + } + return [...lines].sort((a, b) => a - b) +} + +/** Source range [it()-line, next-test-line) for a single test node. */ +function rangeOf( + node: TimeWindowNode, + nodes: NodeMap +): SourceRange | undefined { + const loc = lineOf(node.callSource) + if (!loc) { + return undefined + } + const next = testLinesInFile(nodes, loc.file).find((l) => l > loc.line) + return { + file: loc.file, + start: loc.line, + end: next ?? Number.POSITIVE_INFINITY + } +} + +/** Source ranges of every test node in a subtree (itself + descendants). */ +function rangesForSubtree(node: TimeWindowNode, nodes: NodeMap): SourceRange[] { + const ranges: SourceRange[] = [] + const visit = (n: TimeWindowNode) => { + if (n.kind === 'test') { + const r = rangeOf(n, nodes) + if (r) { + ranges.push(r) + } + } + for (const childUid of n.childUids) { + const child = nodes.get(childUid) + if (child) { + visit(child) + } + } + } + visit(node) + return ranges +} + +/** Source ranges of every test node in the run — used to tell when a command + * belongs to *some other* test (so it must be excluded from this node). */ +function allTestRanges(nodes: NodeMap): SourceRange[] { + const ranges: SourceRange[] = [] + for (const n of nodes.values()) { + if (n.kind === 'test') { + const r = rangeOf(n, nodes) + if (r) { + ranges.push(r) + } + } + } + return ranges +} + +function inRanges( + loc: { file: string; line: number } | undefined, + ranges: SourceRange[] +): boolean { + return ( + loc !== undefined && + ranges.some( + (r) => r.file === loc.file && loc.line >= r.start && loc.line < r.end + ) + ) +} + +/** + * Commands belonging to `node`: + * - call site inside this test's source range → include; + * - call site inside a *different* test's range → exclude; + * - no resolvable call site (e.g. assertion-internal getText) → include when + * it falls in the node's time window. + */ +export function commandsForNode( + node: TimeWindowNode, + nodes: NodeMap, + commands: CommandLogLike[], + nodeWindow: TimeWindow | undefined +): CommandLogLike[] { + const nodeRanges = rangesForSubtree(node, nodes) + const allRanges = allTestRanges(nodes) + const inWindow = (t: number | undefined) => + nodeWindow !== undefined && + t !== undefined && + t >= nodeWindow.start && + t <= nodeWindow.end + return commands.filter((c) => { + const loc = lineOf(c.callSource) + if (inRanges(loc, nodeRanges)) { + return true + } + if (inRanges(loc, allRanges)) { + return false + } + return inWindow(c.timestamp) + }) +} diff --git a/packages/backend/src/baselineStore.ts b/packages/backend/src/baselineStore.ts index d504f9c1..e6ed8f25 100644 --- a/packages/backend/src/baselineStore.ts +++ b/packages/backend/src/baselineStore.ts @@ -17,6 +17,7 @@ import type { TimeWindowNode } from './baseline/types.js' import { freshRun, toMs, pickMin, pickMax } from './baseline/utils.js' +import { commandsForNode } from './baseline/command-attribution.js' export type { PreservedAttempt, PreservedStep } from './baseline/types.js' @@ -41,6 +42,19 @@ class BaselineStore { this.#activeRun.commands.push(...(data as CommandLogLike[])) } return + case 'replaceCommand': { + // A command is sent first, then re-sent with late-attached fields + // (e.g. a per-command screenshot). Apply the replacement so preserved + // baselines carry those fields instead of the initial bare command. + const payload = data as { + oldTimestamp?: number + command?: CommandLogLike + } + if (payload?.command) { + this.#replaceCommand(payload.oldTimestamp, payload.command) + } + return + } case 'consoleLogs': if (Array.isArray(data)) { this.#activeRun.consoleLogs.push(...(data as ConsoleLogLike[])) @@ -65,6 +79,25 @@ class BaselineStore { } } + // Mirrors the app's command replacement: match by stable `id`, then by the + // old timestamp, appending only when neither locates the original. + #replaceCommand(oldTimestamp: number | undefined, command: CommandLogLike) { + const cmds = this.#activeRun.commands + const newId = (command as { id?: number }).id + let idx = -1 + if (typeof newId === 'number') { + idx = cmds.findIndex((c) => (c as { id?: number }).id === newId) + } + if (idx === -1 && oldTimestamp !== undefined) { + idx = cmds.map((c) => c.timestamp).lastIndexOf(oldTimestamp) + } + if (idx === -1) { + cmds.push(command) + } else { + cmds[idx] = command + } + } + #mergeNetwork(incoming: NetworkRequestLike[]) { const byId = new Map() this.#activeRun.networkRequests.forEach((r, i) => { @@ -154,6 +187,16 @@ class BaselineStore { incomingStart !== undefined && incomingStart > existing.end + // A test re-executing (rerun, or a framework retry of a failed test) starts + // a new attempt. Drop its previous attempt's commands so a preserve keeps + // only the latest run — other tests' commands (accumulated across session + // changes within one run) are left intact. + if (isNewRun && kind === 'test') { + this.#activeRun.commands = this.#activeRun.commands.filter( + (c) => c.testUid !== uid + ) + } + const nextStart = isNewRun ? incomingStart : pickMin(existing?.start, incomingStart) @@ -303,12 +346,36 @@ class BaselineStore { } } + /** Tight [min,max] window from a command set's timestamps. */ + #spanOf( + commands: CommandLogLike[] + ): { start: number; end: number } | undefined { + const ts = commands + .map((c) => c.timestamp) + .filter((t): t is number => t !== undefined) + if (ts.length === 0) { + return undefined + } + return { start: Math.min(...ts), end: Math.max(...ts) } + } + snapshot(uid: string, scope: 'test' | 'suite'): PreservedAttempt | undefined { const node = this.#activeRun.nodes.get(uid) if (!node) { return undefined } - const window = this.#windowFor(uid) + const nodeWindow = this.#windowFor(uid) + const commands = commandsForNode( + node, + this.#activeRun.nodes, + this.#activeRun.commands, + nodeWindow + ) + + // Window for the non-uid streams: the matched commands' span, expanded by + // the node window only when they overlap (a stale node window from a + // previous run must not pull in unrelated events). + const window = this.#resolveWindow(this.#spanOf(commands), nodeWindow) if (!window) { return undefined } @@ -328,7 +395,7 @@ class BaselineStore { window, test: this.#buildTestSnapshot(node), steps: steps.length > 0 ? steps : undefined, - commands: this.#activeRun.commands.filter((c) => inWindow(c.timestamp)), + commands, consoleLogs: this.#activeRun.consoleLogs.filter((c) => inWindow(c.timestamp) ), @@ -340,6 +407,30 @@ class BaselineStore { } } + /** Window for the non-uid event streams: the command span, expanded by the + * node window only when the two overlap (same run). A disjoint node window + * is stale — ignore it so the snapshot tracks the run the commands came from. */ + #resolveWindow( + commandSpan: { start: number; end: number } | undefined, + nodeWindow: { start: number; end: number } | undefined + ): { start: number; end: number } | undefined { + if (!commandSpan) { + return nodeWindow + } + if (!nodeWindow) { + return commandSpan + } + const overlap = + nodeWindow.start <= commandSpan.end && nodeWindow.end >= commandSpan.start + if (!overlap) { + return commandSpan + } + return { + start: Math.min(commandSpan.start, nodeWindow.start), + end: Math.max(commandSpan.end, nodeWindow.end) + } + } + preserve(uid: string, scope: 'test' | 'suite'): PreservedAttempt | undefined { const attempt = this.snapshot(uid, scope) if (!attempt) { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a579639f..5cfe8cee 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -293,14 +293,19 @@ function registerWorkerWebSocket(s: FastifyInstance): void { s.get( WS_PATHS.worker, { websocket: true }, - (socket: WebSocket, _req: FastifyRequest) => { + (socket: WebSocket, req: FastifyRequest) => { // Don't drop the message buffer for rerun-child connects (the dashboard // tree dedupes by uid and stale state must survive). Same applies to // baselineStore.activeRun — keep it across reruns so Preserve & Rerun on // a different failed test still finds data; #updateNode handles window // expansion across reruns of the same test. const isRerunChild = testRunner.consumeRerunChildFlag() - if (!isRerunChild) { + // A mid-run session-change reconnect (e.g. after `browser.end()`) reopens + // this socket; keep the accumulated run state so earlier tests' commands + // survive for Preserve & Rerun instead of being wiped. + const isReconnect = + (req.query as { reconnect?: string })?.reconnect === '1' + if (!isRerunChild && !isReconnect) { messageBuffer.length = 0 baselineStore.resetActiveRun() } diff --git a/packages/backend/tests/baselineStore.test.ts b/packages/backend/tests/baselineStore.test.ts index 619d0f65..dbd328aa 100644 --- a/packages/backend/tests/baselineStore.test.ts +++ b/packages/backend/tests/baselineStore.test.ts @@ -57,6 +57,156 @@ describe('baselineStore', () => { expect(baselineStore.snapshot('does-not-exist', 'test')).toBeUndefined() }) + it('attributes commands by call-site line when testUid is mis-tagged (multi-test file)', () => { + const file = '/spec.js' + // Two tests in one file; nightwatch's per-file hooks mis-tag both tests' + // commands with the FIRST test's uid. Source location must still split them. + baselineStore.recordEvent('suites', [ + { + [SUITE_UID]: { + uid: SUITE_UID, + title: 'Suite', + file, + start: 100, + end: 400, + tests: [ + { + uid: 'test-A', + title: 'A', + fullTitle: 'Suite A', + file, + callSource: `${file}:5`, + start: 100, + end: 200 + }, + { + uid: 'test-B', + title: 'B', + fullTitle: 'Suite B', + file, + callSource: `${file}:20`, + start: 250, + end: 350 + } + ], + suites: [] + } + } + ]) + baselineStore.recordEvent('commands', [ + { + timestamp: 110, + command: 'a1', + args: [], + testUid: 'test-A', + callSource: `${file}:7:3` + }, + { + timestamp: 120, + command: 'a2', + args: [], + testUid: 'test-A', + callSource: `${file}:9:3` + }, + // test B's commands are mis-tagged test-A but live below test B's line. + { + timestamp: 260, + command: 'b1', + args: [], + testUid: 'test-A', + callSource: `${file}:22:3` + }, + { + timestamp: 270, + command: 'b2', + args: [], + testUid: 'test-A', + callSource: `${file}:24:3` + } + ]) + + const a = baselineStore.snapshot('test-A', 'test')! + expect(a.commands.map((c) => c.command)).toEqual(['a1', 'a2']) + const b = baselineStore.snapshot('test-B', 'test')! + expect(b.commands.map((c) => c.command)).toEqual(['b1', 'b2']) + }) + + it('applies replaceCommand so late-attached fields (e.g. screenshots) survive into the snapshot', () => { + baselineStore.recordEvent('commands', [ + { timestamp: 250, command: 'getText', args: ['#flash'] } + ]) + // The adapter re-sends the command once a screenshot is attached. + baselineStore.recordEvent('replaceCommand', { + oldTimestamp: 250, + command: { + timestamp: 250, + command: 'getText', + args: ['#flash'], + screenshot: 'data:image/png;base64,AAAA' + } + }) + baselineStore.recordEvent('suites', suite({ start: 200, end: 300 })) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.commands).toHaveLength(1) + expect(snap.commands[0].screenshot).toBe('data:image/png;base64,AAAA') + }) + + it('keeps an indirectly-issued command (no call site) via the time window', () => { + const file = '/spec.js' + baselineStore.recordEvent('suites', [ + { + [SUITE_UID]: { + uid: SUITE_UID, + title: 'Suite', + file, + start: 100, + end: 300, + tests: [ + { + uid: TEST_UID, + title: 'A', + fullTitle: 'Suite A', + file, + callSource: `${file}:5`, + start: 100, + end: 300 + } + ], + suites: [] + } + } + ]) + baselineStore.recordEvent('commands', [ + { timestamp: 120, command: 'click', args: [], callSource: `${file}:7` }, + // assertion-internal getText: no resolvable call site, but in-window. + { timestamp: 150, command: 'getText', args: [] } + ]) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.commands.map((c) => c.command)).toEqual(['click', 'getText']) + }) + + it('drops a re-executed test’s previous commands so only the latest attempt is kept', () => { + // First attempt: 2 commands tagged with the test uid. + baselineStore.recordEvent('commands', [ + { timestamp: 100, command: 'url', args: [], testUid: TEST_UID }, + { timestamp: 110, command: 'getText', args: [], testUid: TEST_UID } + ]) + baselineStore.recordEvent('suites', suite({ start: 100, end: 200 })) + + // The test re-executes (new run: start strictly after the previous end), + // then sends its second attempt's commands. + baselineStore.recordEvent('suites', suite({ start: 500, end: 600 })) + baselineStore.recordEvent('commands', [ + { timestamp: 510, command: 'url', args: [], testUid: TEST_UID }, + { timestamp: 520, command: 'getText', args: [], testUid: TEST_UID } + ]) + + const snap = baselineStore.snapshot(TEST_UID, 'test')! + expect(snap.commands.map((c) => c.timestamp)).toEqual([510, 520]) + }) + it('replaces (not unions) the time window when a new run is detected', () => { baselineStore.recordEvent( 'suites', diff --git a/packages/core/src/session-capturer.ts b/packages/core/src/session-capturer.ts index 115e8360..1cce3000 100644 --- a/packages/core/src/session-capturer.ts +++ b/packages/core/src/session-capturer.ts @@ -33,6 +33,13 @@ import { export interface SessionCapturerOptions { hostname?: string port?: number + /** + * Set when this capturer reconnects mid-run (e.g. after `browser.end()` opens + * a new session). Tells the backend to keep the accumulated run state instead + * of resetting it — otherwise earlier tests' commands are wiped and Preserve + * & Rerun on those tests finds nothing. + */ + reconnect?: boolean } type ConsoleMethod = (typeof CONSOLE_METHODS)[number] @@ -81,9 +88,12 @@ export abstract class SessionCapturerBase { // ── Construction ──────────────────────────────────────────────────────── constructor(opts: SessionCapturerOptions = {}) { - const { hostname, port } = opts + const { hostname, port, reconnect } = opts if (hostname && port) { - this.ws = new WebSocket(`ws://${hostname}:${port}${WS_PATHS.worker}`) + const query = reconnect ? '?reconnect=1' : '' + this.ws = new WebSocket( + `ws://${hostname}:${port}${WS_PATHS.worker}${query}` + ) this.ws.on('open', () => { this.#hasConnected = true this.onWsOpen() diff --git a/packages/nightwatch-devtools/src/session-init.ts b/packages/nightwatch-devtools/src/session-init.ts index 4d6c9913..71a1c21b 100644 --- a/packages/nightwatch-devtools/src/session-init.ts +++ b/packages/nightwatch-devtools/src/session-init.ts @@ -233,7 +233,15 @@ export async function ensureSessionInitialized( // Trace mode: empty opts skip SessionCapturerBase's WS init — no backend // to forward events to anyway. ctx.sessionCapturer = new SessionCapturer( - ctx.mode === 'trace' ? {} : { port: ctx.port, hostname: ctx.hostname }, + ctx.mode === 'trace' + ? {} + : { + port: ctx.port, + hostname: ctx.hostname, + // A session change reopens the WS mid-run — tell the backend to keep + // the accumulated run state so earlier tests' commands survive. + reconnect: Boolean(isSessionChange) + }, browser ) ctx.sessionCapturer.traceMode = ctx.mode diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index 7fc70e42..680b5221 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -119,7 +119,11 @@ export class SessionCapturer extends SessionCapturerBase { readonly snapshotCaptures: Promise[] = [] constructor( - devtoolsOptions: { hostname?: string; port?: number } = {}, + devtoolsOptions: { + hostname?: string + port?: number + reconnect?: boolean + } = {}, browser?: NightwatchBrowser ) { super(devtoolsOptions) diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 43cd694a..ed11e913 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -237,6 +237,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { private resetStack() { this.#commandStack = [] + this.#sessionCapturer.resetRetryTracker() } #resolveCallSourceFromFrame( diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index cc684234..ac25594a 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -12,6 +12,7 @@ import { isNativeMobile } from './mobile.js' import { CAPTURE_PERFORMANCE_SCRIPT, LOG_SOURCES, + RetryTracker, SessionCapturerBase, applyPerformanceData, createConsoleLogEntry, @@ -30,6 +31,8 @@ export class SessionCapturer extends SessionCapturerBase { readonly startWallTime = Date.now() /** Last find-element selector — carried forward to the next element command. */ #lastSelector: string | undefined + /** Collapses internal command retries onto a single entry (see #captureOrReplace). */ + #retryTracker = new RetryTracker() #pendingNetworkRequests = new Map< string, { @@ -197,8 +200,7 @@ export class SessionCapturer extends SessionCapturerBase { } } - this.commandsLog.push(commandLogEntry) - this.sendUpstream('commands', [commandLogEntry]) + this.#captureOrReplace(commandLogEntry) // Capture trace + perf on commands that could trigger a page transition. // Skip on native mobile — scripts can't execute in a native app context. if ( @@ -212,6 +214,46 @@ export class SessionCapturer extends SessionCapturerBase { } } + /** + * Send a command, collapsing internal framework retries onto one entry. WDIO + * polls some commands (e.g. an assertion repeatedly calling `getText`); each + * poll fires `afterCommand`, so without this the UI fills with duplicate + * rows. A matching signature (same command + args + call site) replaces the + * previous entry in place — mirroring the nightwatch and selenium adapters. + */ + #captureOrReplace(entry: CommandLog & { _id?: number }) { + const sig = RetryTracker.signature( + entry.command, + entry.args, + entry.callSource + ) + if (this.#retryTracker.isRetry(sig)) { + const prev = this.commandsLog.find( + (c) => + (c as CommandLog & { _id?: number })._id === this.#retryTracker.lastId + ) as (CommandLog & { _id?: number }) | undefined + const oldTimestamp = prev?.timestamp ?? entry.timestamp + if (prev) { + entry._id = prev._id + Object.assign(prev, entry) + } else { + this.commandsLog.push(entry) + } + this.sendReplaceCommand(oldTimestamp, entry) + this.#retryTracker.recordCapture(sig, entry._id ?? null) + return + } + this.commandsLog.push(entry) + const id = this.sendCommand(entry) + this.#retryTracker.recordCapture(sig, id) + } + + /** Drop retry state at test/scenario boundaries so a deliberate re-issue of + * the same call in the next test counts as fresh, not a retry. */ + resetRetryTracker(): void { + this.#retryTracker.reset() + } + /** * Run the shared Performance API capture script and attach the result to * the given CommandLog entry. Same `CAPTURE_PERFORMANCE_SCRIPT` + From 37de7b5764f3a35548e28584a873bb1128004dc5 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 16 Jun 2026 17:34:23 +0530 Subject: [PATCH 19/22] feat: redesign the Compare tab to match the devtools mockup --- .../app/src/components/workbench/compare.ts | 8 ++++---- .../components/workbench/compare/styles.ts | 20 +++++++++---------- packages/app/src/core/core.css | 9 +++++---- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/workbench/compare.ts b/packages/app/src/components/workbench/compare.ts index ac6d8640..4f040398 100644 --- a/packages/app/src/components/workbench/compare.ts +++ b/packages/app/src/components/workbench/compare.ts @@ -293,11 +293,11 @@ export class DevtoolsCompare extends Element {
    ${errorMessage}
    ` : nothing} +
    +
    ${this.swapped ? 'Latest' : 'Baseline'}
    +
    ${this.swapped ? 'Baseline' : 'Latest'}
    +
    -
    -
    ${this.swapped ? 'Latest' : 'Baseline'}
    -
    ${this.swapped ? 'Baseline' : 'Latest'}
    -
    ${visiblePairs.map((pair) => this.#renderPair(pair, leftCommands, rightCommands, firstDivergent) diff --git a/packages/app/src/components/workbench/compare/styles.ts b/packages/app/src/components/workbench/compare/styles.ts index 34b665e0..7efba776 100644 --- a/packages/app/src/components/workbench/compare/styles.ts +++ b/packages/app/src/components/workbench/compare/styles.ts @@ -129,24 +129,22 @@ export const compareStyles = css` } /* ── Diff body ── */ - .cmp-body { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - padding: 12px 14px; - } + /* Header sits OUTSIDE the scroll container so rows can't scroll above it. */ .cmp-colhead { + flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - position: sticky; - top: 0; - z-index: 2; - margin-bottom: 6px; - padding: 2px 0 8px; + padding: 10px 14px 8px; background: var(--vscode-editor-background); border-bottom: 1px solid var(--vscode-panel-border); } + .cmp-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 12px 14px; + } .col-header { font-size: 11px; font-weight: 700; diff --git a/packages/app/src/core/core.css b/packages/app/src/core/core.css index 3871a220..e6a703e2 100644 --- a/packages/app/src/core/core.css +++ b/packages/app/src/core/core.css @@ -46,19 +46,20 @@ button[data-draggable-id]::after { height 0.12s; pointer-events: none; } +/* The handle is positioned at `position - 3px` (see DragController), so the + real pane boundary is 3px in. Place the line there — centering it (50%) + leaves it ~2px off the boundary, creating a gap where it meets a border. */ button[data-draggable-id].cursor-col-resize::after { top: 0; bottom: 0; - left: 50%; + left: 3px; width: 1px; - transform: translateX(-50%); } button[data-draggable-id].cursor-row-resize::after { left: 0; right: 0; - top: 50%; + top: 3px; height: 1px; - transform: translateY(-50%); } button[data-draggable-id]:hover::after, button[data-draggable-id][data-dragging='dragging']::after { From 21107b09c3053de6d171464e6a2420a6988d341e Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 17 Jun 2026 13:57:20 +0530 Subject: [PATCH 20/22] fix: align the resize-divider line with the pane boundary --- packages/app/src/components/workbench.ts | 4 +--- packages/app/src/core/core.css | 11 ++++++----- packages/app/src/utils/DragController.ts | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index eb35e6c5..b3513b7b 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -296,9 +296,7 @@ export class DevtoolsWorkbench extends Element {

    ${!this.#toolbarCollapsed - ? this.#dragVertical.getSlider( - 'z-[999] -mt-[5px] pointer-events-auto' - ) + ? this.#dragVertical.getSlider('z-[999] pointer-events-auto') : nothing} ${this.#renderWorkbenchTabs()} diff --git a/packages/app/src/core/core.css b/packages/app/src/core/core.css index e6a703e2..0911bcf7 100644 --- a/packages/app/src/core/core.css +++ b/packages/app/src/core/core.css @@ -46,20 +46,21 @@ button[data-draggable-id]::after { height 0.12s; pointer-events: none; } -/* The handle is positioned at `position - 3px` (see DragController), so the - real pane boundary is 3px in. Place the line there — centering it (50%) - leaves it ~2px off the boundary, creating a gap where it meets a border. */ +/* The 10px handle is centered on the pane boundary (position − 5px, see + DragController), so the line sits on the boundary at the handle's center. */ button[data-draggable-id].cursor-col-resize::after { top: 0; bottom: 0; - left: 3px; + left: 50%; width: 1px; + transform: translateX(-50%); } button[data-draggable-id].cursor-row-resize::after { left: 0; right: 0; - top: 3px; + top: 50%; height: 1px; + transform: translateY(-50%); } button[data-draggable-id]:hover::after, button[data-draggable-id][data-dragging='dragging']::after { diff --git a/packages/app/src/utils/DragController.ts b/packages/app/src/utils/DragController.ts index 6d831cb9..593d452d 100644 --- a/packages/app/src/utils/DragController.ts +++ b/packages/app/src/utils/DragController.ts @@ -269,7 +269,7 @@ export class DragController implements ReactiveController { ` From dce27da4946ddca3ea7aef3e7c5446edec8e4ff8 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 17 Jun 2026 16:06:37 +0530 Subject: [PATCH 21/22] docs: Add local demo GIFs for the redesigned dashboard UI --- README.md | 25 ++++++++++++++++--------- assets/actions-command-logs.gif | Bin 0 -> 337316 bytes assets/console-logs.gif | Bin 0 -> 291391 bytes assets/metadata.gif | Bin 0 -> 427870 bytes assets/network-logs.gif | Bin 0 -> 464669 bytes assets/preserve-rerun.gif | Bin 0 -> 467883 bytes assets/screencast.gif | Bin 0 -> 296980 bytes assets/stop-test-runner.gif | Bin 0 -> 154176 bytes assets/test-rerunner.gif | Bin 0 -> 535979 bytes assets/test-runner.gif | Bin 0 -> 225629 bytes assets/testlens.gif | Bin 0 -> 291275 bytes 11 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 assets/actions-command-logs.gif create mode 100644 assets/console-logs.gif create mode 100644 assets/metadata.gif create mode 100644 assets/network-logs.gif create mode 100644 assets/preserve-rerun.gif create mode 100644 assets/screencast.gif create mode 100644 assets/stop-test-runner.gif create mode 100644 assets/test-rerunner.gif create mode 100644 assets/test-runner.gif create mode 100644 assets/testlens.gif diff --git a/README.md b/README.md index f5b7c974..67ca25f8 100644 --- a/README.md +++ b/README.md @@ -125,28 +125,35 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full package map and data flow, ## Demo +### ▶️ Test Runner +Test Runner Demo + ### 🛠️ Test Rerunner & Snapshot -Test Rerunner & Snapshot Demo +Test Rerunner & Snapshot Demo ### 🛑 Stop Test Runner -Stop Test Runner Demo +Stop Test Runner Demo -### 🔍︎ TestLens -TestLens Demo +### ⚡ Actions & Command Logs +Actions & Command Logs Demo ### >_ Console Logs -Console Logs +Console Logs Demo ### 🌐 Network Logs -Network Logs 1 +Network Logs Demo -Network Logs 2 +### 📋 Metadata +Metadata Demo ### 🎬 Session Screencast -Screencast +Session Screencast Demo ### 🐞 Preserve & Rerun -Preserve & Rerun +Preserve & Rerun Demo + +### 🔍︎ TestLens +TestLens Demo ## Installation diff --git a/assets/actions-command-logs.gif b/assets/actions-command-logs.gif new file mode 100644 index 0000000000000000000000000000000000000000..5d7da7094474a1f7907d3c5568608e655d2bc282 GIT binary patch literal 337316 zcmV)EK)}C8Nk%w1VITwX0rvm^0RjUxqA)-d7keQQ79|q)y)1M{g+W|15gQU9H5$aJ z3Bo@RJ}gdBVNwke86ib+^7Hdnd{dZ5TBXO(FQmT;a|aM3F-1KLPC!E~Gc$N-4haek zKWcRh8YA4>-B~eZ6d@B78GL?%Yr4!6AtNg#DVx&ZBw(}~8x(0>7E*6h9SaXyUu(5T zDil)_S&dldN?JQmF(Dlxi;Io1va>)!MG+1XJ}@;A5fbiWI8$yzA|oSnkYyVnmO@WL>+9@2GYb9v{Tv)Azv1c|8yzz!Eoo|Pbar|K2Md8eE5eo=%gfALPYXzW zM`?>&W-2!)nzkY$8(V%$R!}iAG&DkLgHB^N72 z4Hz6zQdB~0nng-Ym4s>u1rbSpfhsI99VsJ0i|?3%7cL+kLm(@CBr!WZLYkYMW?nXf zgon{!JWV)XV=o^pHzFk}FoVe186q4@TSrQ*!Zy5IF)A(` zB{N7V859-ULkb)zD;^>#Bq%FVRa!DTG~?srKTkUZ4HKS4DtN|nTyaw>D=kY+Qhq;8 zNpDLm92`YnJ3n4wLq$nTE;e&=bY_2CH8?w0Z%Yml6J|UyK2l;cRW&GwBQ9Hx{AW2T zPgXuID3s)lel#6gO&=~SAvA~f3=I-7+&ybKMtM(~LK!136CO@uN6+KxA|)v-J1gyv zZ6h=zySuwwUSg@Kt88v^S%6zYl0Bn{LhPJ_rE1n>W@~$TeMUhb78o4T)6%Kl>~nhf z78)UGO@mk$Au3EMIzu{mY9CiFIbU*HOlv+ZAtA!U#NAysR#{zW#ouFMWW`M=cB^t5 zDjV)QOaK4?A^tH(Nk$-JZ*F#Fa&%>6Aa`kWXdq~GbZ~PzFE3?!XK7|GV{dIQc4=f~ zZ!L6hEoW(F03rViPDNBxLqSkQGA=L!0RR91A^8La3IP8AEC2ui03ZYL0RRa90Mjf= zGbD|{g9sBUT*$DY!-o(fN}NcsqQ#3CGiuz(v7^V2AVZ2ANwTELlPFWFTq)6yKqf37 zE?VTLW=$}QU@l}r6U#a^-cNm-_t*DcT=PB{hfJhq+<*iYXyAbePJ|po=wJ}UgH_~!j$d7j zSAZkA*rDMNL&y=$hsHdV9vF_*rxR{qoRCi=%Zw)mD1d0v9}z0(;{_95s4$ZsllWnS zeM7LZh8;}kqfb~g?U<7-l`P_gH{KL7lP}(kP>Fpr;p7JseMq^84L;3;Vhmcl4 zC}?&7<>WU*sNl^Zt(|BSiH)oyUIA}RGl)$#c%csw_82jOEndw3py?!DEV9NVMU-QX z7i;Ka&LV6SGJ_X;urY}_M6BVB9m;M=#IHr@W6LCKaG{S1=6F+t3SLZNiwcuW!fhYE zsG8qni&)tQH|_=1&e9q33CM4*8BQ_T?kii0SrleQ2Jq#p)= zj)1Rys?RmwOfZRKHhEeTFv|3>4msn6aETiDb>jsQM0mkQw?(iqLMuef@ahQ47Gln> zeN0kGl|)PuED@Eg!2~egsK7)eY{;+%5hD;$#0<$+VvaB7%wTdQzARmdK1Ed0hjzR% zQG^G;)Ynx)M|)Ak$+pOV@7r+4Ew`Qe9yA0YDEtd(5LM*=;*|piBl-pp0j9Aq!vSo; z1f@YS(aJSmoC=i2KuxobHsS!n0DEv$QiunISZPi--T=c! IRtS^PwvqrW;dSMH! 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 z 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