From e6abbbe5903ab4e3f77dabf83ec85c8e4a628a66 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 4 Aug 2026 00:20:59 +0530 Subject: [PATCH 1/2] fix(app): keep test-tree and action rows on one line inside their panel --- packages/app/src/components/sidebar.ts | 4 +++ .../app/src/components/sidebar/explorer.ts | 1 + .../app/src/components/sidebar/test-suite.ts | 22 ++++++++++-- .../workbench/actionItems/command.ts | 6 ++-- .../components/workbench/actionItems/group.ts | 3 +- .../components/workbench/actionItems/item.ts | 30 +++++++++++++++- .../workbench/actionItems/mutation.ts | 7 ++-- .../app/src/components/workbench/actions.ts | 4 +++ .../sidebar/explorer/test-entry.test.ts | 31 ++++++++++++++++ .../workbench/actions/command-item.test.ts | 35 +++++++++++++++++++ .../workbench/actions/group-item.test.ts | 25 ++++++++++++- .../workbench/actions/mutation-item.test.ts | 2 +- 12 files changed, 157 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/sidebar.ts b/packages/app/src/components/sidebar.ts index 6f42e6b6..2b82068c 100644 --- a/packages/app/src/components/sidebar.ts +++ b/packages/app/src/components/sidebar.ts @@ -21,6 +21,10 @@ export class DevtoolsSidebar extends Element { flex-direction: column; height: 100%; min-height: 0; + /* Without this, single-line test names make the automatic minimum + width beat the drag handle's flex-basis and the panel outgrows it. */ + min-width: 0; + overflow: hidden; } .top { diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 9f356dde..7b285c09 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -65,6 +65,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { display: flex; flex-direction: column; min-height: 0; + min-width: 0; flex: 1 1 auto; } diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 16231218..3eff1298 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -93,6 +93,11 @@ export class ExplorerTestEntry extends CollapseableEntry { @property({ type: Boolean, reflect: true }) selected = false + /** Set by a click on this row alone. `selected` can't drive it: the tree + * auto-selects the running test, so it would expand a row nobody touched. */ + @property({ type: Boolean, reflect: true }) + revealed = false + @property({ type: Boolean, reflect: true }) root = false @@ -110,6 +115,18 @@ export class ExplorerTestEntry extends CollapseableEntry { font-size: 12.5px; /* matches the icon box height so the icon aligns with the first line */ line-height: 18px; + /* One line per row so every entry in the tree is the same height */ + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* Clicking a row is the only way to read a name it had to clip. Wrapping + anywhere because a name can be one unbroken token (a URL) that plain + wrapping would still clip. */ + :host([revealed]) ::slotted(label) { + white-space: normal; + overflow-wrap: anywhere; } :host([selected]) .row { @@ -192,6 +209,7 @@ export class ExplorerTestEntry extends CollapseableEntry { } #selectEntry() { + this.revealed = !this.revealed if (this.uid) { this.dispatchEvent( new CustomEvent('app-test-select', { @@ -426,13 +444,13 @@ export class ExplorerTestEntry extends CollapseableEntry { > ${this.root ? nothing : this.testStateIcon} - + ${this.#renderToolbar(hasNoChildren)} diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index fdcff3c9..decbc083 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -50,6 +50,7 @@ export class CommandItem extends ActionItem { if (!this.entry) { return } + this.toggleRevealed() window.dispatchEvent( // Typed as the contract, which is what makes the two nullable fields below // a compile error rather than a blank chip in the Log tab. @@ -117,10 +118,7 @@ export class CommandItem extends ActionItem { @click="${() => this.#highlightLine()}" > ${this.iconChip(this.#renderIcon(entry.command))} - ${capitalizeAssertLabel(entry.title ?? entry.command)} ${this.renderTime()} diff --git a/packages/app/src/components/workbench/actionItems/group.ts b/packages/app/src/components/workbench/actionItems/group.ts index 10780076..924f05c5 100644 --- a/packages/app/src/components/workbench/actionItems/group.ts +++ b/packages/app/src/components/workbench/actionItems/group.ts @@ -26,6 +26,7 @@ export class GroupItem extends ActionItem { } #toggle() { + this.toggleRevealed() this.dispatchEvent( new CustomEvent('group-toggle', { detail: { callId: this.group?.callId, expanded: this.expanded }, @@ -51,7 +52,7 @@ export class GroupItem extends ActionItem { : ''}" > ${this.group.title}${formatDuration(this.duration)} ` diff --git a/packages/app/src/components/workbench/actionItems/mutation.ts b/packages/app/src/components/workbench/actionItems/mutation.ts index 52721c33..eed9d536 100644 --- a/packages/app/src/components/workbench/actionItems/mutation.ts +++ b/packages/app/src/components/workbench/actionItems/mutation.ts @@ -28,7 +28,7 @@ export class MutationItem extends ActionItem { ${this.iconChip( html`` )} - element attribute "${mutation.attributeName}" changed @@ -42,7 +42,7 @@ export class MutationItem extends ActionItem { ${this.iconChip( html`` )} - Document loaded + Document loaded ${this.renderTime()} ` } @@ -52,7 +52,7 @@ export class MutationItem extends ActionItem { class="${ICON_CLASS}" >` )} - + ${this.#renderNodeAmount(mutation.addedNodes, 'added')} ${mutation.addedNodes.length && mutation.removedNodes.length ? ' and ' @@ -78,6 +78,7 @@ export class MutationItem extends ActionItem { } #selectMutation() { + this.toggleRevealed() const event = new CustomEvent('app-mutation-select', { detail: this.entry }) window.dispatchEvent(event) this.requestUpdate() diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index c00c26d9..2edcf495 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -43,6 +43,10 @@ export class DevtoolsActions extends Element { display: flex; flex-direction: column; width: 100%; + /* The panel's width is fixed by its drag handle; single-line rows must + clip inside it rather than widen it (or scroll it) to fit. */ + min-width: 0; + overflow-x: hidden; } /* Wraps the rows so the rail spans the full content height — the host diff --git a/packages/app/test-ui/sidebar/explorer/test-entry.test.ts b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts index ed474b0d..c3b16ac1 100644 --- a/packages/app/test-ui/sidebar/explorer/test-entry.test.ts +++ b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts @@ -182,6 +182,37 @@ describe('wdio-test-entry', () => { }) }) + describe('reveal', () => { + it('keeps a name on one line until the row is clicked', async () => { + const row = await mountRow(rowProps(mixedStateRun.failing)) + + expect(row.hasAttribute('revealed')).toBe(false) + }) + + it('reflows the name on click and folds it back on a second click', async () => { + const row = await mountRow(rowProps(mixedStateRun.failing)) + + shadow(row, LABEL_SPAN)?.click() + await settle(row) + expect(row.hasAttribute('revealed')).toBe(true) + + shadow(row, LABEL_SPAN)?.click() + await settle(row) + expect(row.hasAttribute('revealed')).toBe(false) + }) + + // The tree auto-selects the running test, so a selected row that reflowed + // would grow a row nobody clicked — and mid-run, a different one each time. + it('does not reflow a row the tree merely selected', async () => { + const row = await mountRow({ + ...rowProps(mixedStateRun.running), + selected: true + }) + + expect(row.hasAttribute('revealed')).toBe(false) + }) + }) + describe('selection', () => { it('announces its uid on app-test-select when the row is clicked', async () => { const row = await mountRow(rowProps(mixedStateRun.failing)) diff --git a/packages/app/test-ui/workbench/actions/command-item.test.ts b/packages/app/test-ui/workbench/actions/command-item.test.ts index 95e22f6f..8b9f8700 100644 --- a/packages/app/test-ui/workbench/actions/command-item.test.ts +++ b/packages/app/test-ui/workbench/actions/command-item.test.ts @@ -162,6 +162,41 @@ describe('wdio-devtools-command-item', () => { }) }) + describe('reveal', () => { + it('keeps a row on one line until it is clicked', async () => { + const el = await mount(TAG, { + entry: commandLog({ command: 'click' }) + }) + + expect(el.hasAttribute('revealed')).toBe(false) + }) + + it('reflows the label on click and folds it back on a second click', async () => { + const el = await mount(TAG, { + entry: commandLog({ command: 'click' }) + }) + + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + await settle(el) + expect(el.hasAttribute('revealed')).toBe(true) + + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + await settle(el) + expect(el.hasAttribute('revealed')).toBe(false) + }) + + // The player clock marks the action at the playhead as active, so an active + // row that reflowed would make every row height depend on playback. + it('does not reflow a row the playhead merely made active', async () => { + const el = await mount(TAG, { + entry: commandLog({ command: 'click' }), + active: true + }) + + expect(el.hasAttribute('revealed')).toBe(false) + }) + }) + describe('state', () => { it('leaves the row unmarked when the entry has no error', async () => { const el = await mount(TAG, { diff --git a/packages/app/test-ui/workbench/actions/group-item.test.ts b/packages/app/test-ui/workbench/actions/group-item.test.ts index 8b1eb081..db3d8833 100644 --- a/packages/app/test-ui/workbench/actions/group-item.test.ts +++ b/packages/app/test-ui/workbench/actions/group-item.test.ts @@ -5,7 +5,7 @@ import { mount, settle } from '../../support/mount.js' import { shadow, shadowAll, text } from '../../support/queries.js' const TAG = 'wdio-devtools-group-item' -const LABEL = 'span.break-all' +const LABEL = 'span.label' const BADGE = '.ml-auto' const CHEVRON = 'icon-mdi-chevron-right' @@ -153,6 +153,29 @@ describe('wdio-devtools-group-item', () => { expect(received[0]?.detail.expanded).toBe(true) }) + // Groups auto-expand when they failed or hold the active command, so an + // expanded row that reflowed would be a step nobody clicked taking two lines. + it('does not reflow the title of a group that was expanded for it', async () => { + const el = await mount(TAG, { + group: { ...STEP, failed: true }, + expanded: true + }) + + expect(el.hasAttribute('revealed')).toBe(false) + }) + + it('reflows the title on click and folds it back on a second click', async () => { + const el = await mount(TAG, { group: { ...STEP } }) + + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + await settle(el) + expect(el.hasAttribute('revealed')).toBe(true) + + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + await settle(el) + expect(el.hasAttribute('revealed')).toBe(false) + }) + it('does not toggle its own expanded state when clicked', async () => { const el = await mount(TAG, { group: { ...STEP } }) diff --git a/packages/app/test-ui/workbench/actions/mutation-item.test.ts b/packages/app/test-ui/workbench/actions/mutation-item.test.ts index f422018c..a1d59a29 100644 --- a/packages/app/test-ui/workbench/actions/mutation-item.test.ts +++ b/packages/app/test-ui/workbench/actions/mutation-item.test.ts @@ -7,7 +7,7 @@ import { shadow, shadowAll, text } from '../../support/queries.js' import { mutation, documentLoaded } from '../../support/builders.js' const TAG = 'wdio-devtools-mutation-item' -const LABEL = 'span.flex-grow' +const LABEL = 'span.label' const BADGE = '.ml-auto' const PAGE_URL = 'https://the-internet.herokuapp.com/login' From 3e8d5f16d6419f4713b9a812cc8da27e18b903a2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 4 Aug 2026 00:53:16 +0530 Subject: [PATCH 2/2] fix(app): reflow one row at a time in the tree and action list --- .../app/src/components/sidebar/explorer.ts | 9 +- .../app/src/components/sidebar/test-suite.ts | 6 +- .../src/components/workbench/action-tree.ts | 10 +++ .../workbench/actionItems/command.ts | 2 +- .../components/workbench/actionItems/group.ts | 2 +- .../components/workbench/actionItems/item.ts | 24 ++++-- .../workbench/actionItems/mutation.ts | 2 +- .../app/src/components/workbench/actions.ts | 82 ++++++++++++++----- .../test-ui/sidebar/explorer/explorer.test.ts | 48 +++++++++++ .../sidebar/explorer/test-entry.test.ts | 33 +++----- packages/app/test-ui/sidebar/fixtures.ts | 1 + packages/app/test-ui/support/events.ts | 18 ++++ .../test-ui/workbench/actions/actions.test.ts | 45 ++++++++++ .../workbench/actions/command-item.test.ts | 27 ++++-- .../workbench/actions/group-item.test.ts | 23 +++++- packages/app/tests/action-tree.test.ts | 30 ++++++- 16 files changed, 296 insertions(+), 66 deletions(-) create mode 100644 packages/app/test-ui/support/events.ts diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 7b285c09..3e00a8c2 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -50,6 +50,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { #statusFilter: TestStatus | null = null #selectedUid?: string #autoSelectedUid?: string + #revealedUid?: string #filterListener = this.#filterTests.bind(this) #statusFilterListener = this.#applyStatusFilter.bind(this) #selectListener = this.#handleSelect.bind(this) @@ -141,7 +142,12 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { } #handleSelect(event: CustomEvent) { - this.#selectedUid = event.detail + const uid = event.detail + // Only one row shows its full name at a time: clicking a row folds whichever + // was open, and clicking the open one folds it. The event is click-only, so + // the tree auto-selecting the running test never expands anything. + this.#revealedUid = uid === this.#revealedUid ? undefined : uid + this.#selectedUid = uid this.requestUpdate() } @@ -376,6 +382,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { ?has-children="${entry.children && entry.children.length > 0}" ?selected="${entry.uid === (this.#selectedUid ?? this.#autoSelectedUid)}" + ?revealed="${entry.uid === this.#revealedUid}" ?root="${isRoot}" .runDisabled=${this.#isRunDisabled(entry)} .runDisabledReason=${this.#getRunDisabledReason(entry)} diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 3eff1298..d262adeb 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -93,8 +93,9 @@ export class ExplorerTestEntry extends CollapseableEntry { @property({ type: Boolean, reflect: true }) selected = false - /** Set by a click on this row alone. `selected` can't drive it: the tree - * auto-selects the running test, so it would expand a row nobody touched. */ + /** Whether this row shows its name in full. Owned by the explorer, which keeps + * exactly one row revealed; `selected` can't drive it because the tree + * auto-selects the running test, expanding a row nobody touched. */ @property({ type: Boolean, reflect: true }) revealed = false @@ -209,7 +210,6 @@ export class ExplorerTestEntry extends CollapseableEntry { } #selectEntry() { - this.revealed = !this.revealed if (this.uid) { this.dispatchEvent( new CustomEvent('app-test-select', { diff --git a/packages/app/src/components/workbench/action-tree.ts b/packages/app/src/components/workbench/action-tree.ts index 723f83af..4f2a6591 100644 --- a/packages/app/src/components/workbench/action-tree.ts +++ b/packages/app/src/components/workbench/action-tree.ts @@ -21,6 +21,16 @@ export interface CommandRow { export type ActionTreeRow = GroupRow | CommandRow +/** Identity of a render row. Rows are keyed on it so an expand/collapse moves + * each element with the action it belongs to; reusing whatever element sat at + * that index hands the new action the old row's local state. Namespaced so a + * callId that reads as a number can't collide with a command index. */ +export function rowKey(row: ActionTreeRow): string { + return row.kind === 'group' + ? `group:${row.group.callId}` + : `command:${row.commandIndex}` +} + /** Command indices anywhere under a group, nested groups included. */ export function collectCommandIndices(group: TraceActionGroupNode): number[] { const indices: number[] = [] diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index decbc083..c0efce1d 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -50,7 +50,7 @@ export class CommandItem extends ActionItem { if (!this.entry) { return } - this.toggleRevealed() + this.requestReveal() window.dispatchEvent( // Typed as the contract, which is what makes the two nullable fields below // a compile error rather than a blank chip in the Log tab. diff --git a/packages/app/src/components/workbench/actionItems/group.ts b/packages/app/src/components/workbench/actionItems/group.ts index 924f05c5..08d9bad2 100644 --- a/packages/app/src/components/workbench/actionItems/group.ts +++ b/packages/app/src/components/workbench/actionItems/group.ts @@ -26,7 +26,7 @@ export class GroupItem extends ActionItem { } #toggle() { - this.toggleRevealed() + this.requestReveal() this.dispatchEvent( new CustomEvent('group-toggle', { detail: { callId: this.group?.callId, expanded: this.expanded }, diff --git a/packages/app/src/components/workbench/actionItems/item.ts b/packages/app/src/components/workbench/actionItems/item.ts index 6c3fbb24..a931ad5d 100644 --- a/packages/app/src/components/workbench/actionItems/item.ts +++ b/packages/app/src/components/workbench/actionItems/item.ts @@ -7,6 +7,9 @@ import { formatDuration, durationHeat, type DurationHeat } from './duration.js' export type ActionEntry = TraceMutation | CommandLog +/** What a row reports as its identity: an action by reference, a group by key. */ +export type RowRevealKey = string | ActionEntry + /** Icon sized to sit inside the `.ic` chip rendered by `iconChip`. */ export const ICON_CLASS = 'w-[15px] h-[15px] block shrink-0' @@ -33,14 +36,25 @@ export class ActionItem extends Element { @property({ type: Boolean, reflect: true }) failed = false - /** Set by a click on this row alone. `active` can't drive it: the player - * clock marks the action at the playhead, so it would expand rows nobody - * touched and the panel would look as ragged as before. */ + /** Whether this row shows its label in full. Owned by the list, which keeps + * exactly one row revealed; `active` can't drive it because the player clock + * marks the action at the playhead, expanding rows nobody touched. */ @property({ type: Boolean, reflect: true }) revealed = false - protected toggleRevealed() { - this.revealed = !this.revealed + /** Identity this row echoes back on click, for the list to compare. */ + @property({ attribute: false }) + revealKey?: RowRevealKey + + /** Click-only by construction, which is what keeps playback out of it. */ + protected requestReveal() { + this.dispatchEvent( + new CustomEvent('row-reveal', { + detail: this.revealKey, + bubbles: true, + composed: true + }) + ) } static styles = [ diff --git a/packages/app/src/components/workbench/actionItems/mutation.ts b/packages/app/src/components/workbench/actionItems/mutation.ts index eed9d536..77e5b2ea 100644 --- a/packages/app/src/components/workbench/actionItems/mutation.ts +++ b/packages/app/src/components/workbench/actionItems/mutation.ts @@ -78,7 +78,7 @@ export class MutationItem extends ActionItem { } #selectMutation() { - this.toggleRevealed() + this.requestReveal() const event = new CustomEvent('app-mutation-select', { detail: this.entry }) window.dispatchEvent(event) this.requestUpdate() diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 2edcf495..1d7c8cf1 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -1,6 +1,7 @@ import { Element } from '@core/element' import { html, css, nothing } from 'lit' import { customElement, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' import { consume } from '@lit/context' import type { @@ -18,12 +19,14 @@ import '../placeholder.js' import './actionItems/command.js' import './actionItems/group.js' import './actionItems/mutation.js' +import type { RowRevealKey } from './actionItems/item.js' import { elapsedSince } from '../../utils/elapsed.js' import { entryDuration, stepDurations } from './actionItems/duration.js' import { activeSpanAt } from './active-entry.js' import { defaultExpanded, flattenActionTree, + rowKey, type ActionTreeRow } from './action-tree.js' @@ -97,6 +100,17 @@ export class DevtoolsActions extends Element { @state() private expandOverrides: ReadonlyMap = new Map() + // The one row showing its label in full. Held here, not per row, so clicking + // a row folds whichever was open — otherwise every row the user ever clicked + // stays wrapped and the panel goes ragged again. + @state() + private revealedRow?: RowRevealKey + + #onRowReveal = (event: Event) => { + const key = (event as CustomEvent).detail + this.revealedRow = key === this.revealedRow ? undefined : key + } + #onGroupToggle = (event: Event) => { const { callId, expanded } = ( event as CustomEvent<{ callId?: string; expanded: boolean }> @@ -201,8 +215,12 @@ export class DevtoolsActions extends Element { defaultExpanded(group, activeIndex >= 0 ? activeIndex : undefined) const rows = flattenActionTree(rootChildren, isExpanded) const gaps = stepDurations(commands.map((command) => command.timestamp)) - return html`
- ${rows.map((row) => this.#renderTreeRow(row, commands, gaps))} + return html`
+ ${repeat(rows, rowKey, (row) => this.#renderTreeRow(row, commands, gaps))}
` } @@ -212,12 +230,15 @@ export class DevtoolsActions extends Element { gaps: Array ) { const indent = `padding-left: ${row.depth * TREE_INDENT_PX}px` + const key = rowKey(row) if (row.kind === 'group') { return html` ` } @@ -234,6 +255,8 @@ export class DevtoolsActions extends Element { .duration=${duration} .entry=${entry} ?active=${entry === this.activeEntry} + .revealKey=${key} + ?revealed=${key === this.revealedRow} > ` } @@ -249,35 +272,50 @@ export class DevtoolsActions extends Element { } const durations = stepDurations(entries.map((entry) => entry.timestamp)) - const rows = entries.map((entry, index) => { - // Timed against the merged list, so the top row always reads zero — a - // document load can precede the first command. - const elapsedTime = elapsedSince(entries, entry) - const duration = entryDuration(entry, durations[index]) - const active = entry === this.activeEntry + // Keyed by reference, the same identity `activeEntry` uses: timestamps + // aren't unique, and an index would hand a row's local state to whatever + // entry sorts into that position next. + const rows = repeat( + entries, + (entry) => entry, + (entry, index) => { + // Timed against the merged list, so the top row always reads zero — a + // document load can precede the first command. + const elapsedTime = elapsedSince(entries, entry) + const duration = entryDuration(entry, durations[index]) + const active = entry === this.activeEntry + + const revealed = entry === this.revealedRow + + if ('command' in entry) { + return html` + + ` + } - if ('command' in entry) { return html` - + .revealKey=${entry} + ?revealed=${revealed} + > ` } + ) - return html` - - ` - }) - - return html`
${rows}
` + return html`
+ ${rows} +
` } } diff --git a/packages/app/test-ui/sidebar/explorer/explorer.test.ts b/packages/app/test-ui/sidebar/explorer/explorer.test.ts index 3a8a9c88..2334968c 100644 --- a/packages/app/test-ui/sidebar/explorer/explorer.test.ts +++ b/packages/app/test-ui/sidebar/explorer/explorer.test.ts @@ -39,6 +39,7 @@ const NESTED_GROUP = 'wdio-test-suite[slot="children"]' const ROOT_ROW = 'wdio-test-entry[root]' const TEST_ROW = 'wdio-test-entry[entry-type="test"]' const SELECTED_ROW = 'wdio-test-entry[selected]' +const REVEALED_ROW = 'wdio-test-entry[revealed]' const ROW_LABEL = 'wdio-test-entry > label' const EMPTY_STATE = 'p.text-disabledForeground' // Located by its icon, not its title: the title carries the refusal reason when @@ -615,6 +616,53 @@ describe('wdio-devtools-sidebar-explorer', () => { mixedStateRun.passing.uid ]) }) + + // The highlight follows the run on its own; reflowing a name must not, or a + // row nobody clicked grows — and a different one as the run moves on. + it('reflows no name while the highlight tracks the running test', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + expect(rowUids(explorer, REVEALED_ROW)).toEqual([]) + }) + }) + + describe('reveal', () => { + it('reflows the name of the row that is clicked', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + shadow(rowByUid(explorer, mixedStateRun.passing.uid), LABEL_SPAN)?.click() + await settle(explorer) + + expect(rowUids(explorer, REVEALED_ROW)).toEqual([ + mixedStateRun.passing.uid + ]) + }) + + // One name at a time: every row the user had ever clicked used to stay + // wrapped, which is the ragged tree the single-line rows were meant to fix. + it('folds the previous name when another row is clicked', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + shadow(rowByUid(explorer, mixedStateRun.passing.uid), LABEL_SPAN)?.click() + await settle(explorer) + shadow(rowByUid(explorer, mixedStateRun.failing.uid), LABEL_SPAN)?.click() + await settle(explorer) + + expect(rowUids(explorer, REVEALED_ROW)).toEqual([ + mixedStateRun.failing.uid + ]) + }) + + it('folds a reflowed name when its own row is clicked again', async () => { + const explorer = await mountExplorer(mixedStateRun.registry) + + shadow(rowByUid(explorer, mixedStateRun.passing.uid), LABEL_SPAN)?.click() + await settle(explorer) + shadow(rowByUid(explorer, mixedStateRun.passing.uid), LABEL_SPAN)?.click() + await settle(explorer) + + expect(rowUids(explorer, REVEALED_ROW)).toEqual([]) + }) }) describe('header controls', () => { diff --git a/packages/app/test-ui/sidebar/explorer/test-entry.test.ts b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts index c3b16ac1..170f1ecb 100644 --- a/packages/app/test-ui/sidebar/explorer/test-entry.test.ts +++ b/packages/app/test-ui/sidebar/explorer/test-entry.test.ts @@ -2,6 +2,7 @@ import '@components/sidebar/test-suite.js' import type { ExplorerTestEntry } from '@components/sidebar/test-suite.js' import type { TestRunDetail } from '@components/sidebar/types.js' +import { capture } from '../../support/events.js' import { mount, settle } from '../../support/mount.js' import { shadow, shadowAll, text } from '../../support/queries.js' import { @@ -61,22 +62,6 @@ async function mountRow( return row } -function capture( - target: EventTarget, - type: string, - act: () => void -): CustomEvent[] { - const received: CustomEvent[] = [] - const listener = (event: Event) => received.push(event as CustomEvent) - target.addEventListener(type, listener) - try { - act() - } finally { - target.removeEventListener(type, listener) - } - return received -} - describe('wdio-test-entry', () => { describe('state icon', () => { it('shows a check for a passing test', async () => { @@ -189,18 +174,26 @@ describe('wdio-test-entry', () => { expect(row.hasAttribute('revealed')).toBe(false) }) - it('reflows the name on click and folds it back on a second click', async () => { + // The explorer owns which row is reflowed, so one click can fold another + // row; a row that reflowed itself would leave both open. + it('does not reflow itself on click', async () => { const row = await mountRow(rowProps(mixedStateRun.failing)) shadow(row, LABEL_SPAN)?.click() await settle(row) - expect(row.hasAttribute('revealed')).toBe(true) - shadow(row, LABEL_SPAN)?.click() - await settle(row) expect(row.hasAttribute('revealed')).toBe(false) }) + it('reflows its name when the explorer reveals it', async () => { + const row = await mountRow({ + ...rowProps(mixedStateRun.failing), + revealed: true + }) + + expect(row.hasAttribute('revealed')).toBe(true) + }) + // The tree auto-selects the running test, so a selected row that reflowed // would grow a row nobody clicked — and mid-run, a different one each time. it('does not reflow a row the tree merely selected', async () => { diff --git a/packages/app/test-ui/sidebar/fixtures.ts b/packages/app/test-ui/sidebar/fixtures.ts index dcad5dc3..da5a238f 100644 --- a/packages/app/test-ui/sidebar/fixtures.ts +++ b/packages/app/test-ui/sidebar/fixtures.ts @@ -294,6 +294,7 @@ export type TestEntryProps = Partial< | 'suiteType' | 'hasChildren' | 'selected' + | 'revealed' | 'root' | 'runDisabled' | 'runDisabledReason' diff --git a/packages/app/test-ui/support/events.ts b/packages/app/test-ui/support/events.ts new file mode 100644 index 00000000..2ec1cd12 --- /dev/null +++ b/packages/app/test-ui/support/events.ts @@ -0,0 +1,18 @@ +/** Collect the events of one type a component emits while `act` runs. Removes + * the listener even when `act` throws, so one failing spec can't leak into the + * next one's counts. */ +export function capture( + target: EventTarget, + type: string, + act: () => void +): CustomEvent[] { + const received: CustomEvent[] = [] + const listener = (event: Event) => received.push(event as CustomEvent) + target.addEventListener(type, listener) + try { + act() + } finally { + target.removeEventListener(type, listener) + } + return received +} diff --git a/packages/app/test-ui/workbench/actions/actions.test.ts b/packages/app/test-ui/workbench/actions/actions.test.ts index 83bf8980..47bbc7bf 100644 --- a/packages/app/test-ui/workbench/actions/actions.test.ts +++ b/packages/app/test-ui/workbench/actions/actions.test.ts @@ -152,6 +152,12 @@ const elapsedOf = (row: Element) => const clickRow = (row: Element) => shadow(row, 'button')?.click() +/** Actions of the rows currently showing their label in full. */ +const revealedOf = (panel: Element): TimelineEntry[] => + shadowAll(panel, `${COMMAND_ROW}[revealed], ${MUTATION_ROW}[revealed]`) + .map(entryOf) + .filter((entry): entry is TimelineEntry => Boolean(entry)) + describe('wdio-devtools-actions', () => { describe('merged timeline', () => { it('orders commands and document loads into a single list by timestamp', async () => { @@ -437,6 +443,45 @@ describe('wdio-devtools-actions', () => { expect(rows.map(durationOf)).toEqual([300, 300, 200, 700, 700]) }) + // One row at a time: every row the user had ever clicked used to stay + // wrapped, which is the ragged panel the single-line rows were meant to fix. + it('reflows only the row clicked last', async () => { + const panel = await mountPanel({ groups }) + + clickRow(shadowAll(panel, ROWS)[2]) + await settle(panel) + expect(revealedOf(panel)).toEqual([loginTimeline.findInput]) + + clickRow(shadowAll(panel, ROWS)[3]) + await settle(panel) + + expect(revealedOf(panel)).toEqual([loginTimeline.setValue]) + }) + + it('folds a reflowed row when it is clicked again', async () => { + const panel = await mountPanel({ groups }) + + clickRow(shadowAll(panel, ROWS)[2]) + await settle(panel) + clickRow(shadowAll(panel, ROWS)[2]) + await settle(panel) + + expect(revealedOf(panel)).toEqual([]) + }) + + // A step row and an action row compete for the same single reveal. + it('folds a reflowed action when a step row is clicked', async () => { + const panel = await mountPanel({ groups }) + + clickRow(shadowAll(panel, ROWS)[2]) + await settle(panel) + clickRow(shadowAll(panel, ROWS)[0]) + await settle(panel) + + expect(revealedOf(panel)).toEqual([]) + expect(shadowAll(panel, GROUP_ROW)[0].hasAttribute('revealed')).toBe(true) + }) + it("reveals a collapsed group's commands when the group row is clicked", async () => { const panel = await mountPanel({ groups }) diff --git a/packages/app/test-ui/workbench/actions/command-item.test.ts b/packages/app/test-ui/workbench/actions/command-item.test.ts index 8b9f8700..28a41464 100644 --- a/packages/app/test-ui/workbench/actions/command-item.test.ts +++ b/packages/app/test-ui/workbench/actions/command-item.test.ts @@ -4,6 +4,7 @@ import '@components/workbench/actionItems/command.js' import type { CommandItem } from '@components/workbench/actionItems/command.js' import { entryDuration } from '@components/workbench/actionItems/duration.js' +import { capture } from '../../support/events.js' import { mount, settle } from '../../support/mount.js' import { shadow, shadowAll, text } from '../../support/queries.js' import { commandLog } from '../../support/builders.js' @@ -171,17 +172,29 @@ describe('wdio-devtools-command-item', () => { expect(el.hasAttribute('revealed')).toBe(false) }) - it('reflows the label on click and folds it back on a second click', async () => { - const el = await mount(TAG, { - entry: commandLog({ command: 'click' }) - }) + // The row asks; the panel decides, so only one row is ever reflowed. + it('reports its reveal key on click for the panel to act on', async () => { + const entry = commandLog({ command: 'click' }) + const el = await mount(TAG, { entry, revealKey: entry }) - shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) - await settle(el) - expect(el.hasAttribute('revealed')).toBe(true) + const received = capture(el, 'row-reveal', () => + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe(entry) + // Composed so it survives the panel's shadow boundary, where it listens. + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(true) + }) + + it('does not reflow itself on click', async () => { + const entry = commandLog({ command: 'click' }) + const el = await mount(TAG, { entry, revealKey: entry }) shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) await settle(el) + expect(el.hasAttribute('revealed')).toBe(false) }) diff --git a/packages/app/test-ui/workbench/actions/group-item.test.ts b/packages/app/test-ui/workbench/actions/group-item.test.ts index db3d8833..0a2a827d 100644 --- a/packages/app/test-ui/workbench/actions/group-item.test.ts +++ b/packages/app/test-ui/workbench/actions/group-item.test.ts @@ -1,6 +1,7 @@ import '@components/workbench/actionItems/group.js' import type { GroupItem } from '@components/workbench/actionItems/group.js' +import { capture } from '../../support/events.js' import { mount, settle } from '../../support/mount.js' import { shadow, shadowAll, text } from '../../support/queries.js' @@ -164,15 +165,29 @@ describe('wdio-devtools-group-item', () => { expect(el.hasAttribute('revealed')).toBe(false) }) - it('reflows the title on click and folds it back on a second click', async () => { + // The row asks; the panel decides, so only one row is ever reflowed. + it('reports its reveal key on click for the panel to act on', async () => { + const el = await mount(TAG, { + group: { ...STEP }, + revealKey: 'group:step-7' + }) + + const received = capture(el, 'row-reveal', () => + shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) + ) + + expect(received).toHaveLength(1) + expect(received[0]?.detail).toBe('group:step-7') + expect(received[0]?.bubbles).toBe(true) + expect(received[0]?.composed).toBe(true) + }) + + it('does not reflow itself on click', async () => { const el = await mount(TAG, { group: { ...STEP } }) shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) await settle(el) - expect(el.hasAttribute('revealed')).toBe(true) - shadow(el, 'button')?.dispatchEvent(new MouseEvent('click')) - await settle(el) expect(el.hasAttribute('revealed')).toBe(false) }) diff --git a/packages/app/tests/action-tree.test.ts b/packages/app/tests/action-tree.test.ts index b7ea66e9..6a7f1680 100644 --- a/packages/app/tests/action-tree.test.ts +++ b/packages/app/tests/action-tree.test.ts @@ -7,7 +7,8 @@ import type { import { collectCommandIndices, defaultExpanded, - flattenActionTree + flattenActionTree, + rowKey } from '../src/components/workbench/action-tree.js' function group( @@ -93,3 +94,30 @@ describe('flattenActionTree', () => { ).toEqual(['hook', 'fixture', 2, 3]) }) }) + +describe('rowKey', () => { + it('keys a group row on its callId', () => { + expect( + rowKey({ kind: 'group', group: NESTED, depth: 0, expanded: true }) + ).toBe('group:hook') + }) + + it('keys a command row on its command index', () => { + expect(rowKey({ kind: 'command', commandIndex: 2, depth: 1 })).toBe( + 'command:2' + ) + }) + + it('separates a numeric callId from the command index it reads as', () => { + const numeric = rowKey({ + kind: 'group', + group: group('2', []), + depth: 0, + expanded: false + }) + + expect(numeric).not.toBe( + rowKey({ kind: 'command', commandIndex: 2, depth: 0 }) + ) + }) +})