From c8fc3bf8d31542d59b4d4d9eaab1df93ff541dc6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 11 Aug 2026 20:15:23 -0700 Subject: [PATCH] feat(trace-viewer): add "Display Aria" mode (#42211) --- docs/src/test-api/class-testoptions.md | 5 +- packages/isomorphic/ariaSnapshotRenderer.ts | 2 + packages/isomorphic/trace/entries.ts | 2 + packages/isomorphic/trace/traceLoader.ts | 2 + packages/isomorphic/trace/traceModel.ts | 16 ++ packages/isomorphic/trace/traceModernizer.ts | 8 + .../src/server/trace/recorder/tracing.ts | 4 +- packages/playwright/src/common/config.ts | 10 +- packages/playwright/src/worker/testTracing.ts | 2 +- packages/playwright/types/test.d.ts | 2 +- packages/trace-viewer/src/ui/ariaModeView.css | 78 +++++++ packages/trace-viewer/src/ui/ariaModeView.tsx | 215 ++++++++++++++++++ .../src/ui/defaultSettingsView.tsx | 8 + packages/trace-viewer/src/ui/snapshotTab.tsx | 22 +- tests/config/traceViewerFixtures.ts | 2 + tests/library/trace-viewer.spec.ts | 36 +++ .../playwright-test/playwright.trace.spec.ts | 55 +++++ utils/generate_types/overrides-test.d.ts | 2 +- 18 files changed, 459 insertions(+), 12 deletions(-) create mode 100644 packages/trace-viewer/src/ui/ariaModeView.css create mode 100644 packages/trace-viewer/src/ui/ariaModeView.tsx diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index ef94ead33e6a5..5e8dfa649381c 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -684,7 +684,10 @@ export default defineConfig({ - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries"|"retain-on-first-failure"|"retain-on-failure-and-retries">> Trace recording mode. - `attachments` ?<[boolean]> Whether to include test attachments. Defaults to true. Optional. - `screenshots` ?<[boolean]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Defaults to true. Optional. - - `snapshots` ?<[boolean]> Whether to capture DOM snapshot on every action. Defaults to true. Optional. + - `snapshots` ?<[boolean]|[Object]> Which snapshots to capture on every action. Passing `true` is a shortcut for `{ dom: true }`. Defaults to true. Optional. + - `dom` ?<[boolean]> Capture DOM snapshot on every action and record network activity. Optional. + - `aria` ?<[boolean]> Capture aria snapshot of the page on every action. Optional. + - `screen` ?<[boolean]> Capture a screenshot of the page on every action. Optional. - `sources` ?<[boolean]> Whether to include source files for trace actions. Defaults to true. Optional. Whether to record trace for each test. Defaults to `'off'`. The initial run of a test is the "first run"; subsequent runs caused by [retries](../test-retries.md) are "retries". diff --git a/packages/isomorphic/ariaSnapshotRenderer.ts b/packages/isomorphic/ariaSnapshotRenderer.ts index 65cc247816edc..bbf4816db7090 100644 --- a/packages/isomorphic/ariaSnapshotRenderer.ts +++ b/packages/isomorphic/ariaSnapshotRenderer.ts @@ -21,6 +21,7 @@ import type { AriaNodeJSON, AriaSnapshotJSON } from './ariaSnapshot'; export type AriaSnapshotYamlOptions = { convertStringsToRegex?: boolean; + lineToNode?: Map; }; export function renderAriaSnapshotAsYaml(snapshot: AriaSnapshotJSON, options: AriaSnapshotYamlOptions = {}): string { @@ -82,6 +83,7 @@ export function renderAriaSnapshotAsYaml(snapshot: AriaSnapshotJSON, options: Ar return; } + options.lineToNode?.set(lines.length, node); const escapedKey = indent(depth) + '- ' + yamlEscapeKeyIfNeeded(createKey(node)); const props: [string, string][] = []; if (node.url !== undefined) diff --git a/packages/isomorphic/trace/entries.ts b/packages/isomorphic/trace/entries.ts index 71e2f2bfd4f5c..09c71959f30b1 100644 --- a/packages/isomorphic/trace/entries.ts +++ b/packages/isomorphic/trace/entries.ts @@ -37,6 +37,8 @@ export type ContextEntry = { pages: PageEntry[]; resources: ResourceSnapshot[]; actions: ActionEntry[]; + screenshots: trace.ScreenshotTraceEvent[]; + ariaSnapshots: trace.AriaSnapshotTraceEvent[]; events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]; stdio: trace.StdioTraceEvent[]; errors: trace.ErrorTraceEvent[]; diff --git a/packages/isomorphic/trace/traceLoader.ts b/packages/isomorphic/trace/traceLoader.ts index 777bfbcc5f5dc..40c0b0e4ba010 100644 --- a/packages/isomorphic/trace/traceLoader.ts +++ b/packages/isomorphic/trace/traceLoader.ts @@ -150,6 +150,8 @@ function createEmptyContext(): ContextEntry { pages: [], resources: [], actions: [], + screenshots: [], + ariaSnapshots: [], events: [], errors: [], stdio: [], diff --git a/packages/isomorphic/trace/traceModel.ts b/packages/isomorphic/trace/traceModel.ts index e1a37b04ed41d..27185f56e72a1 100644 --- a/packages/isomorphic/trace/traceModel.ts +++ b/packages/isomorphic/trace/traceModel.ts @@ -85,6 +85,8 @@ export class TraceModel { readonly annotations?: trace.TraceEventAnnotation[]; readonly pagerefToTitle = new Map(); private _eventsForAction = new Map(); + private _screenshots = new Map(); + private _ariaSnapshots = new Map(); constructor(traceUri: string, contexts: ContextEntry[]) { const libraryContext = contexts.find(context => context.origin === 'library'); @@ -119,6 +121,12 @@ export class TraceModel { for (const entry of context.resources) this.resources.push({ ...entry, id: `${entry.pageref ?? lastApiContextId}-${entry.startedDateTime}-${entry.request.url}`, contextTitle }); } + for (const context of contexts) { + for (const event of context.screenshots || []) + this._screenshots.set(`${event.callId}/${event.phase}`, event); + for (const event of context.ariaSnapshots || []) + this._ariaSnapshots.set(`${event.callId}/${event.phase}`, event); + } this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []); this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); @@ -148,6 +156,14 @@ export class TraceModel { return this.actions.findLast(a => a.error); } + screenshotForCall(callId: string, phase: trace.ActionPhase): trace.ScreenshotTraceEvent | undefined { + return this._screenshots.get(`${callId}/${phase}`); + } + + ariaSnapshotForCall(callId: string, phase: trace.ActionPhase): trace.AriaSnapshotTraceEvent | undefined { + return this._ariaSnapshots.get(`${callId}/${phase}`); + } + eventsForAction(action: ActionEntry): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] { let result = this._eventsForAction.get(action); if (result) diff --git a/packages/isomorphic/trace/traceModernizer.ts b/packages/isomorphic/trace/traceModernizer.ts index a1ddaf3dd2277..fc28e2dc93d13 100644 --- a/packages/isomorphic/trace/traceModernizer.ts +++ b/packages/isomorphic/trace/traceModernizer.ts @@ -106,6 +106,14 @@ export class TraceModernizer { this._pageEntry(event.pageId).screencastFrames.push(event); break; } + case 'screenshot': { + contextEntry.screenshots.push(event); + break; + } + case 'aria-snapshot': { + contextEntry.ariaSnapshots.push(event); + break; + } case 'before': { this._actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] }); break; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index ccf6d038fd992..8a8509f984f8f 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -510,7 +510,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps } private async _captureScreenshot(progress: Progress, page: Page, phase: trace.ActionPhase): Promise { - const buffer = await page.screenshot(progress, { type: 'png' }).catch(() => undefined); + const buffer = await page.screenshot(progress, { type: 'png', scale: 'css' }).catch(() => undefined); if (!buffer || !this._state?.recording) return; const file = `screenshots/${progress.metadata.id}-${phase}.png`; @@ -520,7 +520,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps } private async _captureAriaSnapshot(progress: Progress, page: Page, phase: trace.ActionPhase): Promise { - const snapshot = await ariaSnapshotJSONForFrame(progress, page.mainFrame(), undefined, { mode: 'default' }).catch(() => null); + const snapshot = await ariaSnapshotJSONForFrame(progress, page.mainFrame(), undefined, { mode: 'default', boxes: true }).catch(() => null); if (!snapshot || !this._state?.recording) return; const buffer = Buffer.from(JSON.stringify(snapshot), 'utf8'); diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 99328f3f9b7eb..e813edd7f0f38 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -21,7 +21,7 @@ import path from 'path'; import { packageJSON } from '../package'; import { getPackageJsonPath, mergeObjects, takeFirst } from '../util'; -import type { Config, Fixtures, Metadata, Project, ReporterDescription } from '../../types/test'; +import type { Config, Fixtures, Metadata, PlaywrightWorkerOptions, Project, ReporterDescription } from '../../types/test'; import type { TestRunnerPluginRegistration } from '../plugins'; import type { ConfigCLIOverrides } from './ipc'; import type { Location } from '../../types/testReporter'; @@ -171,6 +171,12 @@ export class FullProjectInternal { const testDir = takeFirst(pathResolve(configDir, projectConfig.testDir), pathResolve(configDir, config.testDir), fullConfig.configDir); this.snapshotPathTemplate = takeFirst(projectConfig.snapshotPathTemplate, config.snapshotPathTemplate); + const use = mergeObjects(config.use, projectConfig.use, configCLIOverrides.use); + // `--trace ` only forces the tracing mode, preserving other trace options from the config. + const configTrace = takeFirst((projectConfig.use as Partial | undefined)?.trace, (config.use as Partial | undefined)?.trace); + if (typeof configCLIOverrides.use?.trace === 'string' && typeof configTrace === 'object' && configTrace) + use.trace = { ...configTrace, mode: configCLIOverrides.use.trace }; + this.project = { grep: takeFirst(projectConfig.grep, config.grep, defaultGrep), grepInvert: takeFirst(projectConfig.grepInvert, config.grepInvert, null), @@ -186,7 +192,7 @@ export class FullProjectInternal { testIgnore: takeFirst(projectConfig.testIgnore, config.testIgnore, []), testMatch: takeFirst(projectConfig.testMatch, config.testMatch, '**/*.@(spec|test).?(c|m)[jt]s?(x)'), timeout: takeFirst(configCLIOverrides.debug === 'inspector' ? 0 : undefined, configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout), - use: mergeObjects(config.use, projectConfig.use, configCLIOverrides.use), + use, dependencies: projectConfig.dependencies || [], teardown: projectConfig.teardown, ignoreSnapshots: takeFirst(configCLIOverrides.ignoreSnapshots, projectConfig.ignoreSnapshots, config.ignoreSnapshots, false), diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 5eb9aac3be810..e13b8ef7cb56c 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -38,7 +38,7 @@ const version: trace.VERSION = 8; let traceOrdinal = 0; type TraceFixtureValue = PlaywrightWorkerOptions['trace'] | undefined; -type TraceOptions = { screenshots: boolean, snapshots: boolean, sources: boolean, attachments: boolean, live: boolean, mode: TraceMode }; +type TraceOptions = { screenshots: boolean, snapshots: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, sources: boolean, attachments: boolean, live: boolean, mode: TraceMode }; export class TestTracing { private _testInfo: TestInfoImpl; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 1280c1dd3d775..550c2dc1adc3c 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7191,7 +7191,7 @@ export interface PlaywrightWorkerOptions { * * Learn more about [recording trace](https://playwright.dev/docs/test-use-options#recording-options). */ - trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: boolean, sources?: boolean, attachments?: boolean }; + trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; /** * Whether to record video for each test. Defaults to `'off'`. The initial run of a test is the "first run"; * subsequent runs caused by [retries](https://playwright.dev/docs/test-retries) are "retries". diff --git a/packages/trace-viewer/src/ui/ariaModeView.css b/packages/trace-viewer/src/ui/ariaModeView.css new file mode 100644 index 0000000000000..45a1cae73b381 --- /dev/null +++ b/packages/trace-viewer/src/ui/ariaModeView.css @@ -0,0 +1,78 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.aria-mode-view { + flex: auto; +} + +.aria-mode-screenshot { + flex: 1 1 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 10px; + position: relative; +} + +.aria-mode-screenshot img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + box-shadow: 0 12px 28px 0 rgba(0, 0, 0, .2), 0 2px 4px 0 rgba(0, 0, 0, .1); +} + +.aria-mode-highlight { + position: absolute; + pointer-events: none; + background-color: #6fa8dc7f; + outline: 1px solid #6fa8dc; +} + +.aria-mode-snapshot { + flex: 1 1 0; + border-left: 1px solid var(--vscode-panel-border); +} + +.aria-mode-lines { + flex: auto; + overflow: auto; + padding: 8px 0; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 18px; +} + +.aria-mode-line { + white-space: pre; + padding: 0 8px; +} + +.aria-mode-line-hoverable:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.aria-mode-role { + color: var(--vscode-debugTokenExpression-name); +} + +.aria-mode-string { + color: var(--vscode-debugTokenExpression-string); +} + +.aria-mode-attribute { + color: var(--vscode-debugTokenExpression-number); +} diff --git a/packages/trace-viewer/src/ui/ariaModeView.tsx b/packages/trace-viewer/src/ui/ariaModeView.tsx new file mode 100644 index 0000000000000..9e34e37c4438c --- /dev/null +++ b/packages/trace-viewer/src/ui/ariaModeView.tsx @@ -0,0 +1,215 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './ariaModeView.css'; +import * as React from 'react'; +import { nextActionByStartTime, previousActionByEndTime } from '@isomorphic/trace/traceModel'; +import { renderAriaSnapshotAsYaml } from '@isomorphic/ariaSnapshotRenderer'; +import { clsx, useMeasure } from '@web/uiUtils'; +import { PlaceholderPanel } from './placeholderPanel'; + +import type { ActionPhase, ActionTraceEvent, ScreenshotTraceEvent } from '@trace/trace'; +import type { AriaNodeJSON, AriaSnapshotJSON } from '@isomorphic/ariaSnapshot'; +import type { TraceModel } from '@isomorphic/trace/traceModel'; + +export type AriaModeTarget = { + callId: string; + phase: ActionPhase; +}; + +export type AriaModeTargets = { + action?: AriaModeTarget; + before?: AriaModeTarget; + after?: AriaModeTarget; +}; + +// Mirrors the fallback logic in collectSnapshots(), but for screenshot / aria-snapshot events. +export function collectAriaModeTargets(model: TraceModel, action: ActionTraceEvent | undefined): AriaModeTargets { + if (!action) + return {}; + + const hasArtifacts = (callId: string, phase: ActionPhase) => !!model.screenshotForCall(callId, phase) || !!model.ariaSnapshotForCall(callId, phase); + + let before: AriaModeTarget | undefined = hasArtifacts(action.callId, 'before') ? { callId: action.callId, phase: 'before' } : undefined; + if (!before) { + // If the action has no "before" artifacts, use the last available "after" ones. + for (let a = previousActionByEndTime(action); a; a = previousActionByEndTime(a)) { + if (a.endTime <= action.startTime && hasArtifacts(a.callId, 'after')) { + before = { callId: a.callId, phase: 'after' }; + break; + } + } + } + + let after: AriaModeTarget | undefined = hasArtifacts(action.callId, 'after') ? { callId: action.callId, phase: 'after' } : undefined; + if (!after) { + // For test.step and other actions without own artifacts, use the last nested + // or overlapping action, and fall back to the "before" artifacts. + let last: ActionTraceEvent | undefined; + for (let a = nextActionByStartTime(action); a && a.startTime <= action.endTime; a = nextActionByStartTime(a)) { + if (a.endTime > action.endTime || !hasArtifacts(a.callId, 'after')) + continue; + if (last && last.endTime > a.endTime) + continue; + last = a; + } + after = last ? { callId: last.callId, phase: 'after' } : before; + } + + const actionTarget: AriaModeTarget | undefined = hasArtifacts(action.callId, 'action') ? { callId: action.callId, phase: 'action' } : after; + return { action: actionTarget, before, after }; +} + +type Box = { x: number, y: number, width: number, height: number }; + +type AriaSnapshotLine = { + text: string; + box?: Box; +}; + +function buildAriaSnapshotLines(snapshot: AriaSnapshotJSON): AriaSnapshotLine[] { + // Take the boxes aside so they are not rendered as [box=...] attributes in the yaml, + // translating iframe-relative coordinates into the main page coordinates along the way. + const boxes = new Map(); + const extractBoxes = (node: AriaNodeJSON | string, offset: { x: number, y: number }) => { + if (typeof node === 'string') + return; + if (node.box) + boxes.set(node, { ...node.box, x: node.box.x + offset.x, y: node.box.y + offset.y }); + const childOffset = node.role === 'iframe' && node.box ? { x: offset.x + node.box.x, y: offset.y + node.box.y } : offset; + for (const child of node.children || []) + extractBoxes(child, childOffset); + delete node.box; + }; + for (const node of snapshot) + extractBoxes(node, { x: 0, y: 0 }); + + const lineToNode = new Map(); + const text = renderAriaSnapshotAsYaml(snapshot, { lineToNode }); + return text.split('\n').map((line, index) => { + const node = lineToNode.get(index); + return { text: line, box: node ? boxes.get(node) : undefined }; + }); +} + +const tokenRegex = /("(?:[^"\\]|\\.)*")|(\/(?:[^/\\]|\\.)*\/)|(\[[^\]]*\])/g; + +function renderLineTokens(text: string): React.ReactNode[] { + const result: React.ReactNode[] = []; + const roleMatch = text.match(/^(\s*- )([\w-]+)/); + let prefixLength = 0; + if (roleMatch) { + result.push(roleMatch[1]); + result.push({roleMatch[2]}); + prefixLength = roleMatch[0].length; + } + const rest = text.substring(prefixLength); + let lastIndex = 0; + let key = 0; + for (const match of rest.matchAll(tokenRegex)) { + if (match.index! > lastIndex) + result.push(rest.substring(lastIndex, match.index)); + result.push({match[0]}); + lastIndex = match.index! + match[0].length; + } + if (lastIndex < rest.length) + result.push(rest.substring(lastIndex)); + return result; +} + +export const AriaModeView: React.FunctionComponent<{ + model: TraceModel | undefined, + target: AriaModeTarget | undefined, +}> = ({ model, target }) => { + const screenshot = model && target ? model.screenshotForCall(target.callId, target.phase) : undefined; + const ariaSnapshot = model && target ? model.ariaSnapshotForCall(target.callId, target.phase) : undefined; + const [lines, setLines] = React.useState([]); + const [highlightedBox, setHighlightedBox] = React.useState(); + + React.useEffect(() => { + setHighlightedBox(undefined); + if (!model || !ariaSnapshot) { + setLines([]); + return; + } + let cancelled = false; + fetch(model.createRelativeUrl(`file/${ariaSnapshot.file}`)) + .then(response => response.json()) + .then(json => { + if (!cancelled) + setLines(buildAriaSnapshotLines(json)); + }) + .catch(() => { + if (!cancelled) + setLines([]); + }); + return () => { + cancelled = true; + }; + }, [model, ariaSnapshot]); + + if (!screenshot && !ariaSnapshot) + return ; + + return
+ +
+ {ariaSnapshot &&
setHighlightedBox(undefined)}> + {lines.map((line, index) =>
setHighlightedBox(line.box)} + >{renderLineTokens(line.text)}
)} +
} + {!ariaSnapshot && } +
+
; +}; + +const AriaModeScreenshot: React.FunctionComponent<{ + model: TraceModel, + screenshot: ScreenshotTraceEvent | undefined, + highlightedBox: Box | undefined, +}> = ({ model, screenshot, highlightedBox }) => { + const [measure, ref] = useMeasure(); + const [naturalSize, setNaturalSize] = React.useState<{ width: number, height: number } | undefined>(); + + // Trace screenshots are taken with css scale, so image pixels match the aria box viewport coordinates. + let highlightStyle: React.CSSProperties | undefined; + if (highlightedBox && naturalSize && measure.width) { + const padding = 10; + const availableWidth = measure.width - 2 * padding; + const availableHeight = measure.height - 2 * padding; + const scale = Math.min(availableWidth / naturalSize.width, availableHeight / naturalSize.height, 1); + highlightStyle = { + left: padding + (availableWidth - naturalSize.width * scale) / 2 + highlightedBox.x * scale + 'px', + top: padding + (availableHeight - naturalSize.height * scale) / 2 + highlightedBox.y * scale + 'px', + width: highlightedBox.width * scale + 'px', + height: highlightedBox.height * scale + 'px', + }; + } + + return
+ {screenshot && Screenshot setNaturalSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} + />} + {!screenshot && } + {screenshot && highlightStyle &&
} +
; +}; diff --git a/packages/trace-viewer/src/ui/defaultSettingsView.tsx b/packages/trace-viewer/src/ui/defaultSettingsView.tsx index c0d8b5fbcd11e..5844d0b5a92df 100644 --- a/packages/trace-viewer/src/ui/defaultSettingsView.tsx +++ b/packages/trace-viewer/src/ui/defaultSettingsView.tsx @@ -29,6 +29,7 @@ export const DefaultSettingsView: React.FC<{ shouldPopulateCanvasFromScreenshot, setShouldPopulateCanvasFromScreenshot, ] = useSetting('shouldPopulateCanvasFromScreenshot', false); + const [displayAriaMode, setDisplayAriaMode] = useSetting('displayAriaMode', false); const [theme, setTheme] = useThemeSetting(); const [mergeFiles, setMergeFiles] = useSetting('mergeFiles', false); @@ -55,6 +56,13 @@ export const DefaultSettingsView: React.FC<{ name: 'Display canvas content', title: 'Attempt to display the captured canvas appearance in the snapshot preview. May not be accurate.', }, + { + type: 'check', + value: displayAriaMode, + set: setDisplayAriaMode, + name: 'Display Aria', + title: 'Display the action screenshot and aria snapshot instead of the DOM snapshot. Requires a trace recorded with screen and aria snapshots.', + }, ]} /> ); diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index dd24dd8c048d3..828daec4f922a 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -35,6 +35,7 @@ import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot'; import yaml from 'yaml'; import { PlaybackButtons } from './playbackControl'; import type { PlaybackState } from './playbackControl'; +import { AriaModeView, collectAriaModeTargets } from './ariaModeView'; export type HighlightedElement = { locator?: string, @@ -56,10 +57,19 @@ export const SnapshotTabsView: React.FunctionComponent<{ const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [shouldPopulateCanvasFromScreenshot] = useSetting('shouldPopulateCanvasFromScreenshot', false); + const [displayAriaMode] = useSetting('displayAriaMode', false); const snapshots = React.useMemo(() => { return collectSnapshots(action); }, [action]); + const ariaModeTargets = React.useMemo(() => { + return model && displayAriaMode ? collectAriaModeTargets(model, action) : {}; + }, [model, action, displayAriaMode]); + + React.useEffect(() => { + if (displayAriaMode && isInspecting) + setIsInspecting(false); + }, [displayAriaMode, isInspecting, setIsInspecting]); const { snapshotInfoUrl, snapshotUrl, popoutUrl } = React.useMemo(() => { const snapshot = snapshots[snapshotTab]; return model && snapshot ? extendSnapshot(model.traceUri, snapshot, shouldPopulateCanvasFromScreenshot) : { snapshotInfoUrl: undefined, snapshotUrl: undefined, popoutUrl: undefined }; @@ -69,7 +79,7 @@ export const SnapshotTabsView: React.FunctionComponent<{ return
- setIsInspecting(!isInspecting)} /> + setIsInspecting(!isInspecting)} />
{(['action', 'before', 'after'] as const).map(tab => { return
- { + { const win = window.open(snapshotUrls?.popoutUrl || '', '_blank'); win?.addEventListener('DOMContentLoaded', () => { const injectedScript = new InjectedScript(win as any, { isUnderTest, frameSeq: 0, sdkLanguage, testIdAttributeName, stableRafCount: 1, browserName: 'chromium', customEngines: [] }); @@ -91,7 +101,11 @@ export const SnapshotTabsView: React.FunctionComponent<{ }); }} /> - } + {!displayAriaMode && + />}
; }; diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index b41bb394c2390..96950b15961fd 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -53,6 +53,7 @@ class TraceViewerPage { settingsDialog: Locator; themeSetting: Locator; displayCanvasContentSetting: Locator; + displayAriaSetting: Locator; constructor(public page: Page, public process: TestChildProcess) { this.actionTitles = page.locator('.action-title'); @@ -72,6 +73,7 @@ class TraceViewerPage { this.settingsDialog = page.getByTestId('settings-toolbar-dialog'); this.themeSetting = this.settingsDialog.getByRole('combobox', { name: 'Theme' }); this.displayCanvasContentSetting = page.locator('.setting').getByText('Display canvas content'); + this.displayAriaSetting = page.locator('.setting').getByText('Display Aria'); } @step diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index fd023642c6b19..69d37ac90f1b9 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -2107,6 +2107,42 @@ test('should toggle canvas rendering', async ({ runAndTrace, page }) => { expect(snapshotRequest.url()).toContain('shouldPopulateCanvasFromScreenshot'); }); +test('should display aria mode', async ({ runAndTrace, page }) => { + const traceViewer = await runAndTrace(async () => { + await page.setContent(''); + await page.locator('button').click(); + }, { snapshots: { dom: true, aria: true, screen: true } }); + + await traceViewer.showSettings(); + await expect(traceViewer.displayAriaSetting).toBeChecked({ checked: false }); + await traceViewer.displayAriaSetting.click(); + await expect(traceViewer.displayAriaSetting).toBeChecked({ checked: true }); + + await traceViewer.selectAction('Click'); + const ariaModeView = traceViewer.page.locator('.aria-mode-view'); + await expect(ariaModeView.locator('img')).toBeVisible(); + await expect(ariaModeView).toContainText('button "Click me"'); + + // Hovering an aria node highlights its box on the screenshot. + const highlight = ariaModeView.locator('.aria-mode-highlight'); + await expect(highlight).not.toBeVisible(); + await ariaModeView.locator('.aria-mode-line', { hasText: 'button "Click me"' }).hover(); + await expect(highlight).toBeVisible(); + await ariaModeView.locator('img').hover(); + await expect(highlight).not.toBeVisible(); + + // The "Before" tab shows the state before the click. + await traceViewer.selectSnapshot('Before'); + await expect(ariaModeView.locator('img')).toBeVisible(); + await expect(ariaModeView).toContainText('button "Click me"'); + + // Toggling the setting off restores the DOM snapshot. + await traceViewer.showSettings(); + await traceViewer.displayAriaSetting.click(); + await expect(ariaModeView).not.toBeVisible(); + await expect(traceViewer.snapshotContainer).toBeVisible(); +}); + test('should render blob trace received from message', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer(undefined, { host: 'localhost' }); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 6d0eb8f2ab0c7..fea0550e8d64f 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -262,6 +262,61 @@ test('should save sources when requested', async ({ runInlineTest }, testInfo) = expect([...resources.keys()].filter(name => name.startsWith('src/'))).toHaveLength(1); }); +test('should expand snapshots object in trace option', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + trace: { mode: 'on', snapshots: { dom: true, aria: true, screen: true } }, + } + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.setContent(''); + await page.locator('button').click(); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toEqual(0); + expect(result.passed).toBe(1); + + const { model } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const click = model.actions.find(a => a.method === 'click')!; + expect(click.afterSnapshot).toBeTruthy(); + expect(model.screenshotForCall(click.callId, 'after')).toBeTruthy(); + expect(model.ariaSnapshotForCall(click.callId, 'after')).toBeTruthy(); +}); + +test('should keep trace config options when forcing mode with --trace', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + trace: { mode: 'on-first-retry', snapshots: { dom: true, aria: true, screen: true } }, + } + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.setContent(''); + await page.locator('button').click(); + }); + `, + }, { workers: 1, trace: 'on' }); + expect(result.exitCode).toEqual(0); + expect(result.passed).toBe(1); + + // The 'on' mode is forced, while the snapshots configuration is preserved. + const { model } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const click = model.actions.find(a => a.method === 'click')!; + expect(click.afterSnapshot).toBeTruthy(); + expect(model.screenshotForCall(click.callId, 'after')).toBeTruthy(); + expect(model.ariaSnapshotForCall(click.callId, 'after')).toBeTruthy(); +}); + test('should not save sources when not requested', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ 'playwright.config.ts': ` diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index e81e81d448cb8..6c1b5456c64dc 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -270,7 +270,7 @@ export interface PlaywrightWorkerOptions { connectOptions: ConnectOptions | undefined; reuseContext: boolean; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; - trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: boolean, sources?: boolean, attachments?: boolean }; + trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; }