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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/app/src/components/browser/boolean-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** HTML boolean content attributes: presence alone is the state, so a captured
* "false" has to REMOVE one — written verbatim, `checked="false"` reads as
* checked. Curated rather than probed off the element, because a probe both
* misses and misfires: `readonly` has no same-named property (it is `readOnly`),
* while `draggable`, `spellcheck` and `translate` do carry boolean properties
* yet their attributes are enumerated, where "false" is a meaningful value. */
const BOOLEAN_ATTRIBUTES = new Set([
'allowfullscreen',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'disabled',
'formnovalidate',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'selected'
])

/** Whether a captured attribute is one whose presence IS its state. `hidden` and
* every `aria-*` are absent for the same reason `draggable` is: their "false"
* is a real value to write, not an absence to replay. */
export const isBooleanAttribute = (name: string) =>
BOOLEAN_ATTRIBUTES.has(name.toLowerCase())

/** The only boolean attribute the collector reports as a PROPERTY state rather
* than as the attribute's own value: `packages/script` emits `String(el.checked)`
* on every input and change, so a cleared checkbox arrives as "false". Every
* other boolean attribute reaches the wire only through a real mutation record,
* which carries whatever the page set. */
const PROPERTY_STATE_ATTRIBUTES = new Set(['checked'])

/** State a captured boolean attribute carries. A record with no value is off —
* there is no attribute state to set. Otherwise presence IS the state, so any
* value means on, including `''` (`<input disabled>` reaches the wire empty).
*
* A literal "false" is the one value that depends on which attribute it is:
* `checked="false"` is the collector reporting an unchecked box, but
* `disabled="false"` can only be the page having SET that attribute, and a
* boolean attribute is active whenever present — so it replays as disabled.
* The residual ambiguity is a page literally writing `checked="false"`; the
* collector's own signal wins there, since it fires on every field edit. */
export const booleanAttributeOn = (name: string, value?: string) => {
if (value === undefined) {
return false
}
if (!PROPERTY_STATE_ATTRIBUTES.has(name.toLowerCase())) {
return true
}
return value.toLowerCase() !== 'false'
}
4 changes: 3 additions & 1 deletion packages/app/src/components/browser/mutation-at-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export function mutationForCommand(
commands: CommandLog[],
mutations: TraceMutation[]
): TraceMutation | undefined {
if (!command?.timestamp || !mutations.length) {
// `timestamp` is required by the contract and 0 is a real value — the first
// command of a normalized or standalone trace — so only absence bails out.
if (command === undefined || !mutations.length) {
return undefined
}
const idx = commands.indexOf(command)
Expand Down
69 changes: 61 additions & 8 deletions packages/app/src/components/browser/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { commandPageUrl } from './url-at-timestamp.js'
import { mutationForCommand } from './mutation-at-command.js'
import { imageMime } from './trace-timeline-utils.js'
import { booleanAttributeOn, isBooleanAttribute } from './boolean-attribute.js'

import { type ComponentChildren, h, render, type VNode } from 'preact'
import { customElement, query } from 'lit/decorators.js'
Expand Down Expand Up @@ -66,6 +67,10 @@ const COMPONENT = 'wdio-devtools-browser'
@customElement(COMPONENT)
export class DevtoolsBrowser extends Element {
#vdom = document.createDocumentFragment()
/** Fields a field-state record has written the `value` PROPERTY of, which is
* what separates a dirty replayed field from a pristine one. Weak, and every
* replay rebuilds the document, so entries die with the elements they key. */
#fieldStateApplied = new WeakSet<HTMLElement>()
#activeUrl?: string
/** Base64 PNG of the screenshot for the currently selected command, or null. */
#screenshotData: string | null = null
Expand Down Expand Up @@ -451,7 +456,8 @@ export class DevtoolsBrowser extends Element {
}

#handleAttributeMutation(mutation: TraceMutation) {
if (!mutation.attributeName) {
const name = mutation.attributeName
if (!name) {
return
}

Expand All @@ -460,15 +466,62 @@ export class DevtoolsBrowser extends Element {
return
}

const value = mutation.attributeValue ?? ''
el.setAttribute(mutation.attributeName, value)
if (isBooleanAttribute(name)) {
this.#applyBooleanAttribute(
el,
name,
booleanAttributeOn(name, mutation.attributeValue)
)
return
}

// An absent value is the capture's removal signal (`mutations.ts` sends
// undefined where `getAttribute` read null), and `class=""` is not `class`
// gone: presence-based selectors and `aria-label` semantics both turn on it.
if (mutation.attributeValue === undefined) {
el.removeAttribute(name)
this.#clearRemovedFieldValue(el, name)
return
Comment thread
vishnuv688 marked this conversation as resolved.
}

const value = mutation.attributeValue
el.setAttribute(name, value)
// Form-field state lives on the PROPERTY, not just the attribute — mirror it
// so a replayed input shows the captured value / checked state, including a
// field cleared back to empty.
if (mutation.attributeName === 'value' && 'value' in el) {
// so a replayed input shows the captured value, including a field cleared
// back to empty.
if (name === 'value' && 'value' in el) {
;(el as HTMLInputElement).value = value
} else if (mutation.attributeName === 'checked' && 'checked' in el) {
;(el as HTMLInputElement).checked = value === 'true'
this.#fieldStateApplied.add(el)
}
}

/** A pristine field's text IS its `value` attribute, so removing the attribute
* empties the field — but the property stops tracking it once assigned, and
* the snapshot render assigns it. Mirroring the clear restores that coupling,
* EXCEPT where a field-state record already set the property: the captured
* field was dirty then, and a dirty field keeps its text when the attribute
* goes. Only the collector's per-edit records carry that text, so clearing
* there would lose what the user actually typed. */
#clearRemovedFieldValue(el: HTMLElement, name: string) {
if (name !== 'value' || !('value' in el)) {
return
}
if (this.#fieldStateApplied.has(el)) {
return
}
;(el as HTMLInputElement).value = ''
}

/** Presence IS the state of a boolean attribute, so the captured state is
* toggled rather than written — the markup a re-serialization reads then says
* what the replayed page shows. `checked` is mirrored onto the property as
* well because checkedness stops tracking the attribute once anything sets it
* (the captured page's fields arrive as preact property writes); every other
* boolean attribute reflects its property, so the toggle moves both. */
#applyBooleanAttribute(el: HTMLElement, name: string, on: boolean) {
el.toggleAttribute(name, on)
if (name === 'checked' && 'checked' in el) {
;(el as HTMLInputElement).checked = on
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/components/sidebar/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { RunCapabilities } from './types.js'

/** The uid the header control names the whole tree with. `RunnerRequestBody`'s
* `runAll` flag is derived from it, so it is also the signal that a refusal
* has to be judged against `canRunAll` — not against the `canRunSuites` its
* `entryType` would otherwise select. */
export const RUN_ALL_UID = '*'

export const SINGLE_TEST_REFUSAL =
'Single-test execution is not supported by this framework.'
export const SUITE_REFUSAL =
'Suite execution is not supported by this framework.'
export const RUN_ALL_REFUSAL =
'Running every test at once is not supported by this framework.'

export const DEFAULT_CAPABILITIES: RunCapabilities = {
canRunSuites: true,
canRunTests: true,
Expand Down
30 changes: 16 additions & 14 deletions packages/app/src/components/sidebar/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import {
getFramework,
getLaunchCommand,
getRerunCommand,
getRunCapabilities,
getRunAllDisabledReason,
getRunDisabledReason,
isRunAll,
isRunDisabled,
isRunDisabledDetail
} from './runnerCapabilities.js'
import { RUN_ALL_UID } from './constants.js'
import {
BASELINE_API,
TESTS_API,
Expand Down Expand Up @@ -180,7 +182,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
// Forward preserveBaseline so the backend knows whether to drop baselines.
const payload: RunnerRequestBody = {
...detail,
runAll: detail.uid === '*',
runAll: isRunAll(detail),
framework: this.#getFramework(),
specFile: detail.specFile || this.#deriveSpecFile(detail),
configFile: this.#getConfigPath(),
Expand Down Expand Up @@ -293,26 +295,25 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
}

#runAllSuites() {
if (!this.#getRunCapabilities().canRunSuites) {
this.#surfaceCapabilityWarning({
entryType: 'suite',
uid: '*'
} as TestRunDetail)
// Judged against the same capability the control is rendered from, so a
// dispatch that never went through the disabled button is refused too.
const detail: TestRunDetail = { uid: RUN_ALL_UID, entryType: 'suite' }
if (this.#isRunDisabledDetail(detail)) {
this.#surfaceCapabilityWarning(detail)
return
}

// Clear execution data and mark all tests as running
this.dispatchEvent(
new CustomEvent('clear-execution-data', {
detail: { uid: '*', entryType: 'suite' },
detail,
bubbles: true,
composed: true
})
)

const payload: RunnerRequestBody = {
uid: '*',
entryType: 'suite',
...detail,
runAll: true,
framework: this.#getFramework(),
configFile: this.#getConfigPath(),
Expand All @@ -331,8 +332,8 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
#getFramework() {
return getFramework(this.metadata)
}
#getRunCapabilities() {
return getRunCapabilities(this.metadata)
#getRunAllDisabledReason() {
return getRunAllDisabledReason(this.metadata)
}
#isRunDisabled(entry: TestEntry) {
return isRunDisabled(this.metadata, entry)
Expand Down Expand Up @@ -403,7 +404,8 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
}

#renderHeaderToolbar() {
const canRunAll = this.#getRunCapabilities().canRunAll
const runAllRefusal = this.#getRunAllDisabledReason()
const canRunAll = !runAllRefusal
const runBtnCls = canRunAll
? 'hover:bg-toolbarHoverBackground'
: 'opacity-30 cursor-not-allowed'
Expand All @@ -412,7 +414,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
<button
class="p-1 rounded group ${runBtnCls}"
?disabled=${!canRunAll}
title="Run all"
title="${runAllRefusal ?? 'Run all'}"
@click="${() => this.#runAllSuites()}"
>
<icon-mdi-play
Expand Down
38 changes: 31 additions & 7 deletions packages/app/src/components/sidebar/runnerCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ import type {
TestEntry,
TestRunDetail
} from './types.js'
import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js'
import {
DEFAULT_CAPABILITIES,
FRAMEWORK_CAPABILITIES,
RUN_ALL_REFUSAL,
RUN_ALL_UID,
SINGLE_TEST_REFUSAL,
SUITE_REFUSAL
} from './constants.js'

export function isRunAll(detail: Pick<TestRunDetail, 'uid'>): boolean {
return detail.uid === RUN_ALL_UID
}

export function getRunnerOptions(
metadata: Metadata | undefined
Expand Down Expand Up @@ -61,6 +72,11 @@ export function isRunDisabledDetail(
detail: TestRunDetail
): boolean {
const caps = getRunCapabilities(metadata)
// A run-all is not a suite run: it needs the whole-tree entry point the
// header control is rendered from, which several runners don't have.
if (isRunAll(detail)) {
return !caps.canRunAll
}
if (detail.entryType === 'test' && !caps.canRunTests) {
return true
}
Expand All @@ -77,15 +93,23 @@ export function getRunDisabledReason(
if (!isRunDisabled(metadata, entry)) {
return undefined
}
return entry.type === 'test'
? 'Single-test execution is not supported by this framework.'
: 'Suite execution is not supported by this framework.'
return entry.type === 'test' ? SINGLE_TEST_REFUSAL : SUITE_REFUSAL
}

/** Reason the header run-all control is refused, for its tooltip — the only
* surface a real user sees, since the button is disabled and never reaches
* the handler that logs the warning. */
export function getRunAllDisabledReason(
metadata: Metadata | undefined
): string | undefined {
return getRunCapabilities(metadata).canRunAll ? undefined : RUN_ALL_REFUSAL
}

export function getCapabilityWarning(detail: TestRunDetail): string {
return detail.entryType === 'test'
? 'Single-test execution is not supported by this framework.'
: 'Suite execution is disabled by this framework.'
if (isRunAll(detail)) {
return RUN_ALL_REFUSAL
}
return detail.entryType === 'test' ? SINGLE_TEST_REFUSAL : SUITE_REFUSAL
}

export function getConfigPath(
Expand Down
15 changes: 14 additions & 1 deletion packages/app/src/components/workbench/compare/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,28 @@ export const compareStyles = css`
}

/* ── Toolbar ── */
/* One row, always. Wrapping dropped the actions onto a second line at ordinary
panel widths, which read as two unrelated toolbars. The pills shrink and
ellipsize instead, and the controls keep their size so they stay clickable. */
.topbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex-wrap: nowrap;
padding: 10px 14px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
/* Shrinkable, so a long baseline label narrows rather than pushing the
controls out of the row. */
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--vscode-editor-font-family, monospace);
font-size: 11.5px;
padding: 4px 11px;
Expand Down Expand Up @@ -60,12 +69,16 @@ export const compareStyles = css`
.scope {
font-size: 11px;
color: var(--vscode-editorLineNumber-foreground);
white-space: nowrap;
}
.actions-group {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
/* Never shrink or wrap: these are the row's controls. */
flex: 0 0 auto;
white-space: nowrap;
}
.toggle-label {
display: inline-flex;
Expand Down
Loading
Loading