From 5ab3a1e5e3a26a607f398e9d0efa5b9596f10baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20K=C3=B6yk=C4=B1ran?= Date: Sat, 14 Mar 2026 02:43:15 +0300 Subject: [PATCH 1/6] fix: sanitize malformed Unicode in MCP responses (#39625) --- .../src/tools/backend/response.ts | 10 +++- tests/mcp/unicode-serialization.spec.ts | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/mcp/unicode-serialization.spec.ts diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 81b959af61236..578c142d63544 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -157,7 +157,7 @@ export class Response { const content: (TextContent | ImageContent)[] = [ { type: 'text', - text: redactText(text.join('\n')), + text: sanitizeUnicode(redactText(text.join('\n'))), } ]; @@ -271,6 +271,14 @@ function trimMiddle(text: string, maxLength: number) { return text.slice(0, Math.floor(maxLength / 2)) + '...' + text.slice(- 3 - Math.floor(maxLength / 2)); } +/** + * Sanitizes a string to ensure it only contains well-formed Unicode. + * Replaces lone surrogates with U+FFFD using String.prototype.toWellFormed(). + */ +function sanitizeUnicode(text: string): string { + return text.toWellFormed(); +} + function parseSections(text: string): Map { const sections = new Map(); const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element diff --git a/tests/mcp/unicode-serialization.spec.ts b/tests/mcp/unicode-serialization.spec.ts new file mode 100644 index 0000000000000..f5bf825a47294 --- /dev/null +++ b/tests/mcp/unicode-serialization.spec.ts @@ -0,0 +1,56 @@ +/** + * 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 { test, expect } from './fixtures'; + +test.describe('unicode serialization', () => { + test.use({ mcpArgs: ['--no-sandbox'] }); + + test('handles lone surrogates in page content', async ({ client, server }) => { + server.setContent('/', `Text with ${String.fromCharCode(0xD800)} lone surrogate`, 'text/html'); + + const result = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(result.content[0].text).toContain('Page URL:'); + }); + + test('preserves valid emoji and surrogate pairs', async ({ client, server }) => { + server.setContent('/', 'Valid emoji: 💀 skull and text', 'text/html'); + + const result = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(result.content[0].text).toContain('emoji'); + }); + + test('handles console messages with lone surrogates', async ({ startClient, server }) => { + server.setContent('/', ``, 'text/html'); + + const { client } = await startClient({ args: ['--console-level=debug'] }); + + const result = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(result.content[0].text).toBeDefined(); + }); +}); From 157b75b206766dddf50fc48acbdfe78929c10b75 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Mar 2026 17:09:52 -0700 Subject: [PATCH 2/6] test(mcp): verify --storage-state works with remoteEndpoint in config (#39664) --- tests/mcp/storage.spec.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/mcp/storage.spec.ts b/tests/mcp/storage.spec.ts index 748b5092776e5..9f0bfe1e501ce 100644 --- a/tests/mcp/storage.spec.ts +++ b/tests/mcp/storage.spec.ts @@ -169,6 +169,39 @@ test('browser_set_storage_state restores storage state from file', async ({ star }); }); +test('--storage-state option works with remote endpoint in config', async ({ startClient, server, wsEndpoint }, testInfo) => { + const stateFile = testInfo.outputPath('state.json'); + const storageState = { + cookies: [{ + name: 'remoteCookie', + value: 'remoteValue', + domain: 'localhost', + path: '/', + }], + origins: [], + }; + await fs.promises.writeFile(stateFile, JSON.stringify(storageState)); + + const { client } = await startClient({ + config: { browser: { remoteEndpoint: wsEndpoint, isolated: true } }, + args: ['--storage-state', stateFile], + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.EMPTY_PAGE }, + }); + + const result = await client.callTool({ + name: 'browser_evaluate', + arguments: { function: '() => document.cookie' }, + }); + + expect(result).toHaveResponse({ + result: expect.stringContaining('remoteCookie=remoteValue'), + }); +}); + test('browser_storage_state and browser_set_storage_state roundtrip', async ({ startClient, server, mcpBrowser }, testInfo) => { const { client } = await startClient({ config: { capabilities: ['storage'] }, From da4cbf935f9d08dfe8c268ebccec8ebfee4ba68a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Mar 2026 17:22:52 -0700 Subject: [PATCH 3/6] fix(html): don't auto-open HTML report when run by coding agents (#39673) --- packages/playwright/src/reporters/html.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 31548f6ec1e89..bfeabe9ef278f 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -168,7 +168,8 @@ class HtmlReporter implements ReporterV2 { if (process.env.CI || !this._buildResult) return; const { ok, singleTestId } = this._buildResult; - const shouldOpen = !!process.stdin.isTTY && (this._open === 'always' || (!ok && this._open === 'on-failure')); + const isCodingAgent = !!process.env.CLAUDECODE || !!process.env.COPILOT_CLI; + const shouldOpen = !isCodingAgent && !!process.stdin.isTTY && (this._open === 'always' || (!ok && this._open === 'on-failure')); if (shouldOpen) { await showHTMLReport(this._outputFolder, this._host, this._port, singleTestId); } else if (this._options._mode === 'test' && !!process.stdin.isTTY) { From 0494a0ab53ad92f5cbe6858dc9b8329e1d8175bf Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Mar 2026 17:24:55 -0700 Subject: [PATCH 4/6] chore: tv playback teaser (#39634) --- packages/trace-viewer/src/ui/filmStrip.tsx | 2 +- .../trace-viewer/src/ui/playbackControl.css | 91 +++++ .../trace-viewer/src/ui/playbackControl.tsx | 323 ++++++++++++++++++ packages/trace-viewer/src/ui/snapshotTab.tsx | 6 +- packages/trace-viewer/src/ui/timeline.css | 82 +---- packages/trace-viewer/src/ui/timeline.tsx | 111 +----- packages/trace-viewer/src/ui/uiModeView.tsx | 2 +- packages/trace-viewer/src/ui/workbench.css | 1 + packages/trace-viewer/src/ui/workbench.tsx | 38 +-- .../trace-viewer/src/ui/workbenchLoader.css | 20 +- .../trace-viewer/src/ui/workbenchLoader.tsx | 2 +- packages/web/src/components/treeView.tsx | 26 +- tests/library/trace-viewer-scrub.spec.ts | 195 +++++++++++ tests/library/trace-viewer.spec.ts | 7 - tests/playwright-test/reporter-html.spec.ts | 2 +- .../ui-mode-test-output.spec.ts | 2 +- .../playwright-test/ui-mode-test-run.spec.ts | 8 +- .../ui-mode-test-shortcut.spec.ts | 8 +- 18 files changed, 672 insertions(+), 254 deletions(-) create mode 100644 packages/trace-viewer/src/ui/playbackControl.css create mode 100644 packages/trace-viewer/src/ui/playbackControl.tsx create mode 100644 tests/library/trace-viewer-scrub.spec.ts diff --git a/packages/trace-viewer/src/ui/filmStrip.tsx b/packages/trace-viewer/src/ui/filmStrip.tsx index c3910767c1a2a..4fa56cfddc4c3 100644 --- a/packages/trace-viewer/src/ui/filmStrip.tsx +++ b/packages/trace-viewer/src/ui/filmStrip.tsx @@ -76,10 +76,10 @@ export const FilmStrip: React.FunctionComponent<{ top: measure.bottom + 5, left: Math.min(previewPoint!.x, measure.width - (previewSize ? previewSize.width : 0) - 10), }}> - {previewPoint.action &&
{renderAction(previewPoint.action, previewPoint)}
} {previewImage && previewSize &&
} + {previewPoint.action &&
{renderAction(previewPoint.action, previewPoint)}
} } ; diff --git a/packages/trace-viewer/src/ui/playbackControl.css b/packages/trace-viewer/src/ui/playbackControl.css new file mode 100644 index 0000000000000..6299947d05018 --- /dev/null +++ b/packages/trace-viewer/src/ui/playbackControl.css @@ -0,0 +1,91 @@ +/* + 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. +*/ + +.playback-scrubber { + position: relative; + height: 16px; + cursor: pointer; + user-select: none; + flex: none; + outline: none; +} + +.playback-scrubber:focus-visible .playback-thumb { + box-shadow: 0 0 0 2px var(--vscode-focusBorder); +} + +.playback-track { + position: absolute; + top: 7px; + left: 0; + right: 0; + height: 2px; + background-color: var(--vscode-panel-border); + border-radius: 1px; +} + +.playback-track-filled { + position: absolute; + top: 7px; + left: 0; + height: 2px; + background-color: var(--vscode-focusBorder); + border-radius: 1px; +} + +.playback-track-filled.animated { + transition: width 150ms ease-out; +} + +.playback-tick { + position: absolute; + top: 4px; + width: 1px; + height: 8px; + background-color: var(--vscode-foreground); +} + +.playback-thumb { + position: absolute; + top: 3px; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background-color: var(--vscode-focusBorder); + z-index: 1; +} + +.playback-thumb.animated { + transition: left 150ms ease-out; +} + +.playback-speed { + background: none; + border: none; + color: inherit; + font-size: 12px; + cursor: pointer; + padding: 2px 6px; + min-width: 36px; + text-align: center; + user-select: none; + border-radius: 3px; +} + +.playback-speed:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/packages/trace-viewer/src/ui/playbackControl.tsx b/packages/trace-viewer/src/ui/playbackControl.tsx new file mode 100644 index 0000000000000..e97bc082d4eb3 --- /dev/null +++ b/packages/trace-viewer/src/ui/playbackControl.tsx @@ -0,0 +1,323 @@ +/* + 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 { ToolbarButton } from '@web/components/toolbarButton'; +import * as React from 'react'; +import type { ActionTraceEventInContext } from '@isomorphic/trace/traceModel'; +import type { Boundaries } from './geometry'; +import './playbackControl.css'; + +const speeds = [0.5, 1, 2]; + +export type PlaybackState = { + playing: boolean; + speed: number; + currentIndex: number; + percent: number; + animating: boolean; + togglePlay: () => void; + stop: () => void; + prev: () => void; + next: () => void; + cycleSpeed: () => void; + onScrubberMouseDown: (e: React.MouseEvent) => void; + scrubberRef: React.RefObject; + actionsLength: number; + canPrev: boolean; + canNext: boolean; + canStop: boolean; + ticks: number[] | undefined; +}; + +export function usePlayback( + actions: ActionTraceEventInContext[], + selectedAction: ActionTraceEventInContext | undefined, + onActionSelected: (action: ActionTraceEventInContext) => void, + timeWindow: Boundaries | undefined, + boundaries: Boundaries, +): PlaybackState { + const [playing, setPlaying] = React.useState(false); + const [speedIndex, setSpeedIndex] = React.useState(1); + const [dragging, setDragging] = React.useState(false); + const [dragFraction, setDragFraction] = React.useState(undefined); + const [cursorTime, setCursorTime] = React.useState(undefined); + const speed = speeds[speedIndex]; + + const currentIndex = selectedAction ? actions.indexOf(selectedAction) : -1; + + // Scrubber scale always matches the timeline boundaries (1:1 with timeline grid). + const fullMin = boundaries.minimum; + const fullMax = boundaries.maximum; + const fullDuration = fullMax - fullMin || 1; + + // Playback boundaries: constrained to time window when selected. + const windowMin = timeWindow ? timeWindow.minimum : fullMin; + const windowMax = timeWindow ? timeWindow.maximum : fullMax; + + // Actions within the effective window. + const windowActions = React.useMemo(() => { + if (!timeWindow) + return actions; + return actions.filter(a => a.startTime >= timeWindow.minimum && a.startTime <= timeWindow.maximum); + }, [actions, timeWindow]); + + // First and last action indices within the window (in the full actions array). + const firstWindowIndex = windowActions.length ? actions.indexOf(windowActions[0]) : 0; + const lastWindowIndex = windowActions.length ? actions.indexOf(windowActions[windowActions.length - 1]) : actions.length - 1; + + const actionsRef = React.useRef(actions); + actionsRef.current = actions; + + const onActionSelectedRef = React.useRef(onActionSelected); + onActionSelectedRef.current = onActionSelected; + + const scrubberRef = React.useRef(null); + + const actionIndexAtTime = React.useCallback((t: number): number => { + let lo = 0, hi = actions.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (actions[mid].startTime <= t) + lo = mid; + else + hi = mid - 1; + } + if (lo < actions.length - 1) { + const distPrev = t - actions[lo].startTime; + const distNext = actions[lo + 1].startTime - t; + if (distNext < distPrev) + lo = lo + 1; + } + // Clamp to window bounds. + return Math.max(firstWindowIndex, Math.min(lastWindowIndex, lo)); + }, [actions, firstWindowIndex, lastWindowIndex]); + + const selectedTime = selectedAction ? selectedAction.startTime : fullMin; + + let percent: number; + if (dragging && dragFraction !== undefined) + percent = dragFraction * 100; + else if (playing && cursorTime !== undefined) + percent = Math.max(0, Math.min(100, ((cursorTime - fullMin) / fullDuration) * 100)); + else + percent = Math.max(0, Math.min(100, ((selectedTime - fullMin) / fullDuration) * 100)); + + // Refs for raf closure. + const windowMinRef = React.useRef(windowMin); + windowMinRef.current = windowMin; + const windowMaxRef = React.useRef(windowMax); + windowMaxRef.current = windowMax; + + React.useEffect(() => { + if (!playing) + return; + let rafId: number; + let lastFrameTime: number | undefined; + let traceTime = selectedTime; + // If starting from before the window, jump to window start. + if (traceTime < windowMinRef.current) + traceTime = windowMinRef.current; + let lastSelectedIndex = currentIndex; + + setCursorTime(traceTime); + + const tick = (now: number) => { + if (lastFrameTime !== undefined) { + const delta = (now - lastFrameTime) * speed; + traceTime = Math.min(traceTime + delta, windowMaxRef.current); + } + lastFrameTime = now; + setCursorTime(traceTime); + + const idx = actionIndexAtTime(traceTime); + if (idx !== lastSelectedIndex) { + lastSelectedIndex = idx; + onActionSelectedRef.current(actionsRef.current[idx]); + } + + if (traceTime >= windowMaxRef.current) { + setPlaying(false); + return; + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playing, speed]); + + React.useEffect(() => { + if (!playing) + setCursorTime(undefined); + }, [playing]); + + const togglePlay = React.useCallback(() => { + if (!actions.length) + return; + // Always restart from the window start when at the end (or beyond the window). + const atEnd = currentIndex >= lastWindowIndex; + if (!playing && atEnd) + onActionSelected(actions[firstWindowIndex]); + setPlaying(!playing); + }, [playing, actions, currentIndex, onActionSelected, firstWindowIndex, lastWindowIndex]); + + const stop = React.useCallback(() => { + setPlaying(false); + if (actions.length) + onActionSelected(actions[firstWindowIndex]); + }, [actions, onActionSelected, firstWindowIndex]); + + const prev = React.useCallback(() => { + const target = Math.max(currentIndex - 1, firstWindowIndex); + if (target !== currentIndex) + onActionSelected(actions[target]); + }, [actions, currentIndex, onActionSelected, firstWindowIndex]); + + const next = React.useCallback(() => { + const target = Math.min(currentIndex + 1, lastWindowIndex); + if (target !== currentIndex) + onActionSelected(actions[target]); + }, [actions, currentIndex, onActionSelected, lastWindowIndex]); + + const cycleSpeed = React.useCallback(() => { + setSpeedIndex(i => (i + 1) % speeds.length); + }, []); + + React.useEffect(() => { + setPlaying(false); + }, [actions]); + + const fractionFromMouseEvent = React.useCallback((e: MouseEvent | React.MouseEvent) => { + const rect = scrubberRef.current!.getBoundingClientRect(); + return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + }, []); + + const selectActionAtFraction = React.useCallback((fraction: number) => { + if (!actions.length) + return; + const t = fullMin + fraction * fullDuration; + const idx = actionIndexAtTime(t); + onActionSelectedRef.current(actionsRef.current[idx]); + }, [actions, fullMin, fullDuration, actionIndexAtTime]); + + const dragCleanupRef = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + return () => dragCleanupRef.current?.(); + }, []); + + const onScrubberMouseDown = React.useCallback((e: React.MouseEvent) => { + if (!actions.length || e.button !== 0) + return; + e.preventDefault(); + e.stopPropagation(); + scrubberRef.current?.focus(); + setDragging(true); + setPlaying(false); + const fraction = fractionFromMouseEvent(e); + setDragFraction(fraction); + selectActionAtFraction(fraction); + + const onMouseMove = (me: MouseEvent) => { + const f = fractionFromMouseEvent(me); + setDragFraction(f); + selectActionAtFraction(f); + }; + const onMouseUp = (me: MouseEvent) => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + dragCleanupRef.current = null; + const f = fractionFromMouseEvent(me); + selectActionAtFraction(f); + setDragFraction(undefined); + setDragging(false); + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + dragCleanupRef.current = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [actions, selectActionAtFraction, fractionFromMouseEvent]); + + const animating = !playing && !dragging; + const ticks = actions.length > 0 && actions.length <= 200 ? actions.map(a => ((a.startTime - fullMin) / fullDuration) * 100) : undefined; + + const canPrev = currentIndex > firstWindowIndex; + const canNext = currentIndex < lastWindowIndex; + const canStop = playing || currentIndex > firstWindowIndex; + + return { + playing, speed, currentIndex, percent, animating, + togglePlay, stop, prev, next, cycleSpeed, + onScrubberMouseDown, scrubberRef, actionsLength: actions.length, + canPrev, canNext, canStop, ticks, + }; +} + +export const PlaybackButtons: React.FC<{ + playback: PlaybackState; +}> = ({ playback }) => { + return <> + + + + + + ; +}; + +export const PlaybackScrubber: React.FC<{ + playback: PlaybackState; +}> = ({ playback }) => { + const onKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + playback.prev(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + playback.next(); + } + }, [playback]); + + return
+
+
+ {playback.ticks?.map((p, i) => ( +
+ ))} +
+
; +}; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index fd8277e757787..707e8f058a8db 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -33,6 +33,8 @@ import { BrowserFrame } from './browserFrame'; import type { ElementInfo } from '@recorder/recorderTypes'; import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot'; import yaml from 'yaml'; +import { PlaybackButtons } from './playbackControl'; +import type { PlaybackState } from './playbackControl'; export type HighlightedElement = { locator?: string, @@ -49,7 +51,8 @@ export const SnapshotTabsView: React.FunctionComponent<{ setIsInspecting: (isInspecting: boolean) => void, highlightedElement: HighlightedElement, setHighlightedElement: (element: HighlightedElement) => void, -}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedElement, setHighlightedElement }) => { + playback: PlaybackState +}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedElement, setHighlightedElement, playback }) => { const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [shouldPopulateCanvasFromScreenshot] = useSetting('shouldPopulateCanvasFromScreenshot', false); @@ -79,6 +82,7 @@ export const SnapshotTabsView: React.FunctionComponent<{ })}
+ { const win = window.open(snapshotUrls?.popoutUrl || '', '_blank'); win?.addEventListener('DOMContentLoaded', () => { diff --git a/packages/trace-viewer/src/ui/timeline.css b/packages/trace-viewer/src/ui/timeline.css index a94b4c113541c..ccb98e2e5bb77 100644 --- a/packages/trace-viewer/src/ui/timeline.css +++ b/packages/trace-viewer/src/ui/timeline.css @@ -24,11 +24,10 @@ position: relative; display: flex; flex-direction: column; - padding: 20px 0 4px; + padding-top: 16px; cursor: text; user-select: none; margin-left: 10px; - overflow-x: clip; } .timeline-divider { @@ -52,14 +51,6 @@ cursor: pointer; } -.timeline-lane { - pointer-events: none; - overflow: hidden; - flex: none; - height: 20px; - position: relative; -} - .timeline-grid { position: absolute; top: 0; @@ -68,80 +59,11 @@ left: 0; } -.timeline-bars { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - pointer-events: none; -} - -.timeline-bar { - position: absolute; - --action-color: gray; - --action-background-color: #88888802; - border-top: 2px solid var(--action-color); -} - -.timeline-bar.active { - background-color: var(--action-background-color); -} - -.timeline-bar.action { - --action-color: var(--vscode-charts-orange); - --action-background-color: #d1861666; -} - -.timeline-bar.action.error { - --action-color: var(--vscode-charts-red); - --action-background-color: #e5140066; -} - -.timeline-bar.network { - --action-color: var(--vscode-charts-blue); - --action-background-color: #1a85ff66; -} - -.timeline-bar.console-message { - --action-color: var(--vscode-charts-purple); - --action-background-color: #1a85ff66; -} - -:root.dark-mode .timeline-bar.action.error { - --action-color: var(--vscode-errorForeground); - --action-background-color: #f4877166; -} - -.timeline-label { - position: absolute; - top: 0; - bottom: 0; - margin-left: 2px; - background-color: var(--vscode-panel-background); - justify-content: center; - display: none; - white-space: nowrap; -} - -.timeline-label.selected { - display: flex; -} - -.timeline-marker { - display: none; - position: absolute; - top: 0; - bottom: 0; - pointer-events: none; - border-left: 3px solid var(--light-pink); -} - .timeline-window { display: flex; position: absolute; top: 0; - bottom: 0; + bottom: 20px; left: 0; right: 0; pointer-events: none; diff --git a/packages/trace-viewer/src/ui/timeline.tsx b/packages/trace-viewer/src/ui/timeline.tsx index 5b8479304832e..8da798d424ea4 100644 --- a/packages/trace-viewer/src/ui/timeline.tsx +++ b/packages/trace-viewer/src/ui/timeline.tsx @@ -14,43 +14,26 @@ * limitations under the License. */ -import { useSetting, clsx, msToString, useMeasure } from '@web/uiUtils'; +import { useSetting, msToString, useMeasure } from '@web/uiUtils'; import { GlassPane } from '@web/shared/glassPane'; import * as React from 'react'; import type { Boundaries } from './geometry'; import { FilmStrip } from './filmStrip'; import type { FilmStripPreviewPoint } from './filmStrip'; -import type { ActionTraceEventInContext, ResourceEntry, TraceModel } from '@isomorphic/trace/traceModel'; +import type { ActionTraceEventInContext, TraceModel } from '@isomorphic/trace/traceModel'; import './timeline.css'; import type { Language } from '@isomorphic/locatorGenerators'; -import type { ConsoleEntry } from './consoleTab'; import type { ActionGroup } from '@isomorphic/protocolFormatter'; -type TimelineBar = { - action?: ActionTraceEventInContext; - resourceKey?: string; - consoleMessage?: ConsoleEntry; - leftPosition: number; - rightPosition: number; - leftTime: number; - rightTime: number; - active: boolean; - error: boolean; -}; - export const Timeline: React.FunctionComponent<{ model: TraceModel | undefined, - consoleEntries: ConsoleEntry[] | undefined, - networkResources: ResourceEntry[] | undefined, boundaries: Boundaries, - highlightedAction: ActionTraceEventInContext | undefined, - highlightedResourceKey: string | undefined, - highlightedConsoleEntryOrdinal: number | undefined, onSelected: (action: ActionTraceEventInContext) => void, selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language, -}> = ({ model, boundaries, consoleEntries, networkResources, onSelected, highlightedAction, highlightedResourceKey, highlightedConsoleEntryOrdinal, selectedTime, setSelectedTime, sdkLanguage }) => { + scrubber?: React.ReactNode, +}> = ({ model, boundaries, onSelected, selectedTime, setSelectedTime, sdkLanguage, scrubber }) => { const [measure, ref] = useMeasure(); const [dragWindow, setDragWindow] = React.useState<{ startX: number, endX: number, pivot?: number, type: 'resize' | 'move' } | undefined>(); const [previewPoint, setPreviewPoint] = React.useState(); @@ -71,62 +54,6 @@ export const Timeline: React.FunctionComponent<{ const actions = React.useMemo(() => model?.filteredActions(actionsFilter), [model, actionsFilter]); - const bars = React.useMemo(() => { - const bars: TimelineBar[] = []; - for (const entry of actions || []) { - bars.push({ - action: entry, - leftTime: entry.startTime, - rightTime: entry.endTime || boundaries.maximum, - leftPosition: timeToPosition(measure.width, boundaries, entry.startTime), - rightPosition: timeToPosition(measure.width, boundaries, entry.endTime || boundaries.maximum), - active: false, - error: !!entry.error, - }); - } - - for (const resource of model?.resources || []) { - const startTime = resource._monotonicTime!; - const endTime = resource._monotonicTime! + resource.time; - bars.push({ - resourceKey: resource.id, - leftTime: startTime, - rightTime: endTime, - leftPosition: timeToPosition(measure.width, boundaries, startTime), - rightPosition: timeToPosition(measure.width, boundaries, endTime), - active: false, - error: false, - }); - } - - for (const consoleMessage of consoleEntries || []) { - bars.push({ - consoleMessage, - leftTime: consoleMessage.timestamp, - rightTime: consoleMessage.timestamp, - leftPosition: timeToPosition(measure.width, boundaries, consoleMessage.timestamp), - rightPosition: timeToPosition(measure.width, boundaries, consoleMessage.timestamp), - active: false, - error: consoleMessage.isError, - }); - } - - return bars; - }, [model, actions, consoleEntries, boundaries, measure]); - - React.useMemo(() => { - for (const bar of bars) { - if (highlightedAction) - bar.active = bar.action === highlightedAction; - else if (highlightedResourceKey) - bar.active = bar.resourceKey === highlightedResourceKey; - else if (highlightedConsoleEntryOrdinal !== undefined) - bar.active = bar.consoleMessage === consoleEntries?.[highlightedConsoleEntryOrdinal]; - else - bar.active = false; - } - }, [bars, highlightedAction, highlightedResourceKey, highlightedConsoleEntryOrdinal, consoleEntries]); - const onMouseDown = React.useCallback((event: React.MouseEvent) => { setPreviewPoint(undefined); if (!ref.current) @@ -249,32 +176,8 @@ export const Timeline: React.FunctionComponent<{
; }) }
-
-
{ - bars - .filter(bar => !bar.action || bar.action.class !== 'Test') - .map((bar, index) => { - return
; - }) - }
-
+ {scrubber} {selectedTime &&
@@ -325,7 +228,3 @@ function timeToPosition(clientWidth: number, boundaries: Boundaries, time: numbe function positionToTime(clientWidth: number, boundaries: Boundaries, x: number): number { return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum; } - -function barTop(bar: TimelineBar): number { - return bar.resourceKey ? 25 : 20; -} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index faeba7146831d..78caf2f9d778c 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -494,7 +494,7 @@ export const UIModeView: React.FC<{}> = ({
Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)
} - testServerConnection?.stopTests({})} disabled={!isRunningTest || isLoading}> + testServerConnection?.stopTests({})} disabled={!isRunningTest || isLoading} testId={'stop-button'}> { setWatchedTreeIds({ value: new Set() }); setWatchAll(!watchAll); diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbench.css index 928b2e5845063..5f778a2d0d20e 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbench.css @@ -48,6 +48,7 @@ .workbench-action-filter { margin-top: 3px; border-bottom: 1px solid var(--vscode-panel-border); + flex: none; } .workbench-action-filter input[type=search] { diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 59ca7c7819444..77a4a290b6c2d 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -28,6 +28,7 @@ import { SourceTab } from './sourceTab'; import { TabbedPane } from '@web/components/tabbedPane'; import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; import { Timeline } from './timeline'; +import { usePlayback, PlaybackScrubber } from './playbackControl'; import { MetadataView } from './metadataView'; import { AttachmentsTab } from './attachmentsTab'; import { AnnotationsTab } from './annotationsTab'; @@ -84,9 +85,7 @@ const PartitionedWorkbench: React.FunctionComponent('selectedTime'); const [highlightedCallId, setHighlightedCallId] = usePartitionedState('highlightedCallId'); const [revealedErrorKey, setRevealedErrorKey] = usePartitionedState('revealedErrorKey'); - const [highlightedConsoleMessageOrdinal, setHighlightedConsoleMessageOrdinal] = usePartitionedState('highlightedConsoleMessageOrdinal'); const [revealedAttachmentCallId, setRevealedAttachmentCallId] = usePartitionedState<{ callId: string } | undefined>('revealedAttachmentCallId'); - const [highlightedResourceKey, setHighlightedResourceKey] = usePartitionedState('highlightedResourceKey'); const [treeState, setTreeState] = usePartitionedState('treeState', { expandedItems: new Map() }); const [actionFilterText, setActionFilterText] = React.useState(''); @@ -152,6 +151,19 @@ const PartitionedWorkbench: React.FunctionComponent { + const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; + if (boundaries.minimum > boundaries.maximum) { + boundaries.minimum = 0; + boundaries.maximum = 30000; + } + // Leave some nice free space on the right hand side. + boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + return { boundaries }; + }, [model]); + + const playback = usePlayback(actions || [], selectedAction, onActionSelected, selectedTime, boundaries); + const selectPropertiesTab = React.useCallback((tab: string) => { setSelectedPropertiesTab(tab); if (tab !== 'inspector') @@ -252,14 +264,13 @@ const PartitionedWorkbench: React.FunctionComponent setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })} - onEntryHovered={setHighlightedConsoleMessageOrdinal} /> }; const networkTab: TabbedPaneTabModel = { id: 'network', title: 'Network', count: networkModel.resources.length, - render: () => + render: () => }; const attachmentsTab: TabbedPaneTabModel = { id: 'attachments', @@ -295,16 +306,6 @@ const PartitionedWorkbench: React.FunctionComponent { - const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 }; - if (boundaries.minimum > boundaries.maximum) { - boundaries.minimum = 0; - boundaries.maximum = 30000; - } - // Leave some nice free space on the right hand side. - boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; - return { boundaries }; - }, [model]); let time: number = 0; if (!isLive && model && model.endTime >= 0) @@ -360,16 +361,12 @@ const PartitionedWorkbench: React.FunctionComponent {!hideTimeline && } />} } + setHighlightedElement={elementPicked} + playback={playback} />} sidebar={ -
+
Playwright logo
diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index b2f98af0dcbb2..2f406180bbda1 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -73,7 +73,6 @@ export function TreeView({ const itemListRef = React.useRef(null); const [highlightedItem, setHighlightedItem] = React.useState(); - const [isKeyboardNavigation, setIsKeyboardNavigation] = React.useState(false); React.useEffect(() => { onHighlighted?.(highlightedItem); @@ -180,12 +179,9 @@ export function TreeView({ } } - // scrollIntoViewIfNeeded(element || undefined); onHighlighted?.(undefined); - if (newSelectedItem) { - setIsKeyboardNavigation(true); + if (newSelectedItem) onSelected?.(newSelectedItem); - } setHighlightedItem(undefined); }} ref={itemListRef} @@ -207,9 +203,7 @@ export function TreeView({ setHighlightedItem={setHighlightedItem} render={render} icon={icon} - title={title} - isKeyboardNavigation={isKeyboardNavigation} - setIsKeyboardNavigation={setIsKeyboardNavigation} />; + title={title} />; })}
; @@ -229,8 +223,6 @@ type TreeItemHeaderProps = { render: (item: T) => React.ReactNode, title?: (item: T) => string, icon?: (item: T) => string | undefined, - isKeyboardNavigation: boolean, - setIsKeyboardNavigation: (value: boolean) => void, }; export function TreeItemHeader({ @@ -246,18 +238,14 @@ export function TreeItemHeader({ toggleSubtree, render, title, - icon, - isKeyboardNavigation, - setIsKeyboardNavigation }: TreeItemHeaderProps) { + icon }: TreeItemHeaderProps) { const groupId = React.useId(); const itemRef = React.useRef(null); React.useEffect(() => { - if (selectedItem === item && isKeyboardNavigation && itemRef.current) { + if (selectedItem?.id === item.id && itemRef.current) scrollIntoViewIfNeeded(itemRef.current); - setIsKeyboardNavigation(false); - } - }, [item, selectedItem, isKeyboardNavigation, setIsKeyboardNavigation]); + }, [item.id, selectedItem?.id]); const itemData = treeItems.get(item)!; const indentation = itemData.depth; @@ -320,9 +308,7 @@ export function TreeItemHeader({ setHighlightedItem={setHighlightedItem} render={render} title={title} - icon={icon} - isKeyboardNavigation={isKeyboardNavigation} - setIsKeyboardNavigation={setIsKeyboardNavigation} />; + icon={icon} />; })}
}
; diff --git a/tests/library/trace-viewer-scrub.spec.ts b/tests/library/trace-viewer-scrub.spec.ts new file mode 100644 index 0000000000000..c1c609c0553e7 --- /dev/null +++ b/tests/library/trace-viewer-scrub.spec.ts @@ -0,0 +1,195 @@ +/** + * 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 type { TraceViewerFixtures } from '../config/traceViewerFixtures'; +import { traceViewerFixtures } from '../config/traceViewerFixtures'; +import { expect, playwrightTest } from '../config/browserTest'; + +const test = playwrightTest.extend(traceViewerFixtures); + +test.skip(({ trace }) => trace === 'on'); +test.skip(({ mode }) => mode.startsWith('service')); +test.skip(process.env.PW_CLOCK === 'frozen'); +test.slow(); + +test('should show playback controls', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + }); + const page = traceViewer.page; + await expect(page.getByRole('slider', { name: 'Playback position' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Play' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Previous action' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next action' })).toBeVisible(); + await expect(page.locator('.playback-speed')).toBeVisible(); +}); + +test('should navigate with next and previous buttons', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + + // Select the first action. + await traceViewer.selectAction('Set content'); + + // Click next to advance. + await page.getByRole('button', { name: 'Next action' }).click(); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Click/); + + // Click next again. + await page.getByRole('button', { name: 'Next action' }).click(); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Set content/); + + // Click previous to go back. + await page.getByRole('button', { name: 'Previous action' }).click(); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Click/); +}); + +test('should cycle playback speed', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + }); + const page = traceViewer.page; + const speedButton = page.locator('.playback-speed'); + + await expect(speedButton).toHaveText('1x'); + await speedButton.click(); + await expect(speedButton).toHaveText('2x'); + await speedButton.click(); + await expect(speedButton).toHaveText('0.5x'); + await speedButton.click(); + await expect(speedButton).toHaveText('1x'); +}); + +test('should stop and reset to first action', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + + // Navigate to a later action. + await traceViewer.selectAction('Click'); + + // Click stop — should reset to first action. + await page.getByRole('button', { name: 'Stop' }).click(); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Set content/); +}); + +test('should support keyboard navigation on scrubber', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + const scrubber = page.getByRole('slider', { name: 'Playback position' }); + + // Select first action and focus scrubber. + await traceViewer.selectAction('Set content'); + await scrubber.focus(); + + // ArrowRight should go to next action. + await page.keyboard.press('ArrowRight'); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Click/); + + // ArrowRight again. + await page.keyboard.press('ArrowRight'); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Set content/); + + // ArrowLeft should go back. + await page.keyboard.press('ArrowLeft'); + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Click/); +}); + +test('should have tick marks on scrubber', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + + // There should be tick marks for each action. + const ticks = page.locator('.playback-tick'); + await expect(ticks).toHaveCount(3); +}); + +test('should play and auto-stop at end', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + }); + const page = traceViewer.page; + + // Select first action. + await traceViewer.selectAction('Set content'); + + // Hit play. + await page.getByRole('button', { name: 'Play' }).click(); + + // Should auto-stop — play button should reappear (not pause). + await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 }); + + // Should have advanced to last action. + await expect(traceViewer.actionsTree.getByRole('treeitem', { selected: true })).toHaveText(/Click/); +}); + +test('should update scrubber aria-valuenow', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + const scrubber = page.getByRole('slider', { name: 'Playback position' }); + + // Select first action - should have low value. + await traceViewer.selectAction('Set content'); + const val1 = await scrubber.getAttribute('aria-valuenow'); + + // Select last action - should have higher value. + await traceViewer.selectAction('Set content', 1); + const val2 = await scrubber.getAttribute('aria-valuenow'); + + expect(Number(val2)).toBeGreaterThan(Number(val1)); +}); + +test('should drag scrubber to select action', async ({ runAndTrace, page: actionPage }) => { + const traceViewer = await runAndTrace(async () => { + await actionPage.setContent(''); + await actionPage.click('button'); + await actionPage.setContent(''); + }); + const page = traceViewer.page; + const scrubber = page.getByRole('slider', { name: 'Playback position' }); + + // Click near the end of the scrubber to select a later action. + const box = await scrubber.boundingBox(); + expect(box).toBeTruthy(); + await page.mouse.click(box!.x + box!.width * 0.95, box!.y + box!.height / 2); + + // Should have selected a later action (not the first one). + const scrubberValue = await scrubber.getAttribute('aria-valuenow'); + expect(Number(scrubberValue)).toBeGreaterThan(50); +}); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index d3d12eb2475e9..f3fda49466892 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -309,13 +309,6 @@ test('should contain action info', async ({ showTraceViewer }) => { ]); }); -test('should render network bars', async ({ page, runAndTrace, server }) => { - const traceViewer = await runAndTrace(async () => { - await page.goto(server.EMPTY_PAGE); - }); - await expect(traceViewer.page.locator('.timeline-bar.network')).toHaveCount(1); -}); - test('should render console', async ({ showTraceViewer, browserName }) => { const traceViewer = await showTraceViewer(traceFile); await traceViewer.showConsoleTab(); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index e2dbfadef157b..cc996651d1543 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -652,7 +652,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await page.getByRole('link', { name: 'passes' }).click(); await page.click('img'); await expect(page.locator('.progress-dialog')).toBeHidden(); - await expect(page.locator('.workbench-loader > .header > .title')).toHaveText('a.test.js:3 › passes'); + await expect(page.locator('.workbench-loader > .workbench-loader-header > .title')).toHaveText('a.test.js:3 › passes'); }); test('should show multi trace source', async ({ runInlineTest, page, server, showReport }) => { diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index c3426f8f46731..12195618934bd 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -236,7 +236,7 @@ test('should stream console messages live', async ({ runUITest }) => { 'I was logged', 'I was clicked', ]); - await page.getByTitle('Stop').click(); + await page.getByTestId('stop-button').click(); }); test('should print beforeAll console messages once', async ({ runUITest }, testInfo) => { diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index e99c13b9a5cf8..6d79692a8ebb4 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -97,7 +97,7 @@ test('should show running progress', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)'); - await page.getByTitle('Stop').click(); + await page.getByTestId('stop-button').click(); await expect(page.getByTestId('status-line')).toHaveText('1/4 passed (25%)'); await page.getByTitle('Reload').click(); await expect(page.getByTestId('status-line')).toBeHidden(); @@ -365,7 +365,7 @@ test('should stop', async ({ runUITest }) => { }); await expect(page.getByTitle('Run all')).toBeEnabled(); - await expect(page.getByTitle('Stop')).toBeDisabled(); + await expect(page.getByTestId('stop-button')).toBeDisabled(); await page.getByTitle('Run all').click(); @@ -388,9 +388,9 @@ test('should stop', async ({ runUITest }) => { `); await expect(page.getByTitle('Run all')).toBeDisabled(); - await expect(page.getByTitle('Stop')).toBeEnabled(); + await expect(page.getByTestId('stop-button')).toBeEnabled(); - await page.getByTitle('Stop').click(); + await page.getByTestId('stop-button').click(); await expect.poll(dumpTestTree(page)).toBe(` ▼ ◯ a.test.ts diff --git a/tests/playwright-test/ui-mode-test-shortcut.spec.ts b/tests/playwright-test/ui-mode-test-shortcut.spec.ts index 9dd73f3e902d1..6d9b0eba5d665 100644 --- a/tests/playwright-test/ui-mode-test-shortcut.spec.ts +++ b/tests/playwright-test/ui-mode-test-shortcut.spec.ts @@ -32,7 +32,7 @@ test('should run tests', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); await expect(page.getByTitle('Run all')).toBeEnabled(); - await expect(page.getByTitle('Stop')).toBeDisabled(); + await expect(page.getByTestId('stop-button')).toBeDisabled(); await page.getByPlaceholder('Filter (e.g. text, @tag)').fill('test 3'); await page.keyboard.press('F5'); @@ -64,7 +64,7 @@ test('should stop tests', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); await expect(page.getByTitle('Run all')).toBeEnabled(); - await expect(page.getByTitle('Stop')).toBeDisabled(); + await expect(page.getByTestId('stop-button')).toBeDisabled(); await page.getByTitle('Run all').click(); @@ -87,7 +87,7 @@ test('should stop tests', async ({ runUITest }) => { `); await expect(page.getByTitle('Run all')).toBeDisabled(); - await expect(page.getByTitle('Stop')).toBeEnabled(); + await expect(page.getByTestId('stop-button')).toBeEnabled(); await page.keyboard.press('Shift+F5'); @@ -104,7 +104,7 @@ test('should toggle Terminal', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); await expect(page.getByTitle('Run all')).toBeEnabled(); - await expect(page.getByTitle('Stop')).toBeDisabled(); + await expect(page.getByTestId('stop-button')).toBeDisabled(); await expect(page.getByTestId('output')).toBeHidden(); From 47caab240330c9b7a1d8a67c875d330e2ee3db8d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Mar 2026 18:29:54 -0700 Subject: [PATCH 5/6] fix(mcp): guard toWellFormed() for Node 18 compatibility (#39674) --- packages/playwright-core/src/tools/backend/response.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 578c142d63544..d686353c66c9a 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -276,7 +276,9 @@ function trimMiddle(text: string, maxLength: number) { * Replaces lone surrogates with U+FFFD using String.prototype.toWellFormed(). */ function sanitizeUnicode(text: string): string { - return text.toWellFormed(); + if ((String.prototype as any).toWellFormed) + return text.toWellFormed(); + return text; } function parseSections(text: string): Map { From 98872a4d69b96cfc4901b09eccc76a69c152aa29 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Mar 2026 18:31:04 -0700 Subject: [PATCH 6/6] chore: build error context in pw fixture (#39670) --- packages/html-reporter/src/DEPS.list | 1 + packages/html-reporter/src/testResultView.tsx | 31 +++-- packages/html-reporter/vite.config.ts | 1 + packages/playwright/src/DEPS.list | 2 +- packages/playwright/src/errorContext.ts | 121 +++++++++++++++++ packages/playwright/src/index.ts | 34 ++--- packages/trace-viewer/src/ui/errorsTab.tsx | 65 ++------- packages/web/src/shared/prompts.ts | 124 ------------------ .../playwright.artifacts.spec.ts | 100 ++++++++++++++ .../playwright.connect.spec.ts | 7 +- .../playwright-test/playwright.trace.spec.ts | 7 + tests/playwright-test/reporter-line.spec.ts | 2 + tests/playwright-test/reporter-list.spec.ts | 2 + 13 files changed, 285 insertions(+), 212 deletions(-) create mode 100644 packages/playwright/src/errorContext.ts delete mode 100644 packages/web/src/shared/prompts.ts diff --git a/packages/html-reporter/src/DEPS.list b/packages/html-reporter/src/DEPS.list index c7e0c4943dc78..7d6766edb953c 100644 --- a/packages/html-reporter/src/DEPS.list +++ b/packages/html-reporter/src/DEPS.list @@ -1,6 +1,7 @@ [*] @playwright/experimental-ct-react @web/** +@isomorphic/** [chip.spec.tsx] *** diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 0a689be253710..cb8e6313978c6 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -28,9 +28,9 @@ import { CodeSnippet, PromptButton, TestScreenshotErrorView } from './testErrorV import * as icons from './icons'; import './testResultView.css'; import { useAsyncMemo } from '@web/uiUtils'; -import { copyPrompt } from '@web/shared/prompts'; import type { LoadedReport } from './loadedReport'; import { TestCaseListView } from './testFileView'; +import { stripAnsiEscapes } from '@isomorphic/stringUtils'; interface ImageDiffWithAnchors extends ImageDiff { anchors: string[]; @@ -94,25 +94,28 @@ export const TestResultView: React.FC<{ const prompt = useAsyncMemo(async () => { if (report.json().options?.noCopyPrompt) return undefined; + if (!errorContext) + return undefined; + + let text = errorContext.path ? await fetch(errorContext.path).then(r => r.text()) : errorContext.body; + if (!text) + return undefined; const stdoutAttachment = result.attachments.find(a => a.name === 'stdout'); const stderrAttachment = result.attachments.find(a => a.name === 'stderr'); const stdout = stdoutAttachment?.body && stdoutAttachment.contentType === 'text/plain' ? stdoutAttachment.body : undefined; const stderr = stderrAttachment?.body && stderrAttachment.contentType === 'text/plain' ? stderrAttachment.body : undefined; + if (stdout) + text += '\n\n# Stdout\n\n```\n' + stripAnsiEscapes(stdout) + '\n```'; + if (stderr) + text += '\n\n# Stderr\n\n```\n' + stripAnsiEscapes(stderr) + '\n```'; + + const metadata = report.json().metadata; + if (metadata?.gitDiff) + text += '\n\n# Local changes\n\n```diff\n' + metadata.gitDiff + '\n```'; - return await copyPrompt({ - testInfo: [ - `- Name: ${test.path.join(' >> ')} >> ${test.title}`, - `- Location: ${test.location.file}:${test.location.line}:${test.location.column}` - ].join('\n'), - metadata: report.json().metadata, - errorContext: errorContext?.path ? await fetch(errorContext.path!).then(r => r.text()) : errorContext?.body, - errors: result.errors, - buildCodeFrame: async error => error.codeframe, - stdout, - stderr, - }); - }, [test, errorContext, report, result], undefined); + return text; + }, [errorContext, report, result], undefined); return
{!!errors.length && diff --git a/packages/html-reporter/vite.config.ts b/packages/html-reporter/vite.config.ts index f0032feb279e9..c37eb2cd8746f 100644 --- a/packages/html-reporter/vite.config.ts +++ b/packages/html-reporter/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ resolve: { alias: { '@web': path.resolve(__dirname, '../web/src'), + '@isomorphic': path.resolve(__dirname, '../playwright-core/src/utils/isomorphic'), }, }, build: { diff --git a/packages/playwright/src/DEPS.list b/packages/playwright/src/DEPS.list index 63df33ec4f297..1db4544ec5d7d 100644 --- a/packages/playwright/src/DEPS.list +++ b/packages/playwright/src/DEPS.list @@ -9,6 +9,7 @@ common/ [index.ts] @testIsomorphic/** ./prompt.ts +./errorContext.ts ./worker/testTracing.ts ./mcp/sdk/ ./mcp/test/ @@ -18,4 +19,3 @@ common/ ** [errorContext.ts] -./transform/babelBundle.ts diff --git a/packages/playwright/src/errorContext.ts b/packages/playwright/src/errorContext.ts new file mode 100644 index 0000000000000..a7655f4ce0cb7 --- /dev/null +++ b/packages/playwright/src/errorContext.ts @@ -0,0 +1,121 @@ +/** + * 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 fs from 'fs'; +import path from 'path'; + +import { parseErrorStack, stripAnsiEscapes } from 'playwright-core/lib/utils'; + +import type { TestInfoError } from '../types/test'; + +const fixTestInstructions = `# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. +`; + +export function buildErrorContext(options: { + titlePath: string[]; + location: { file: string; line: number; column: number }; + errors: TestInfoError[]; + pageSnapshot?: string; +}): string | undefined { + const { titlePath, location, errors, pageSnapshot } = options; + + const meaningfulErrors = errors.filter(e => !!e.message); + + if (!meaningfulErrors.length && !pageSnapshot) + return undefined; + + const lines = [ + fixTestInstructions, + '# Test info', + '', + `- Name: ${titlePath.join(' >> ')}`, + `- Location: ${location.file}:${location.line}:${location.column}`, + ]; + + if (meaningfulErrors.length) { + lines.push('', '# Error details'); + + for (const error of meaningfulErrors) { + lines.push( + '', + '```', + stripAnsiEscapes(error.message || ''), + '```', + ); + } + } + + if (pageSnapshot) { + lines.push( + '', + '# Page snapshot', + '', + '```yaml', + pageSnapshot, + '```', + ); + } + + const lastError = meaningfulErrors[meaningfulErrors.length - 1]; + const codeFrame = lastError ? buildCodeFrame(lastError, location) : undefined; + if (codeFrame) { + lines.push( + '', + '# Test source', + '', + '```ts', + codeFrame, + '```', + ); + } + + return lines.join('\n'); +} + +function buildCodeFrame(error: TestInfoError, testLocation: { file: string; line: number; column: number }): string | undefined { + const stack = error.stack; + if (!stack) + return undefined; + + const parsed = parseErrorStack(stack, path.sep); + const errorLocation = parsed.location; + if (!errorLocation) + return undefined; + + let source: string; + try { + source = fs.readFileSync(errorLocation.file, 'utf8'); + } catch { + return undefined; + } + + const sourceLines = source.split('\n'); + const linesAbove = 100; + const linesBelow = 100; + const start = Math.max(0, errorLocation.line - linesAbove - 1); + const end = Math.min(sourceLines.length, errorLocation.line + linesBelow); + const scope = sourceLines.slice(start, end); + const lineNumberWidth = String(end).length; + const message = stripAnsiEscapes(error.message || '').split('\n')[0] || undefined; + const frame = scope.map((line, index) => `${(start + index + 1) === errorLocation.line ? '> ' : ' '}${(start + index + 1).toString().padEnd(lineNumberWidth, ' ')} | ${line}`); + if (message) + frame.splice(errorLocation.line - start, 0, `${' '.repeat(lineNumberWidth + 2)} | ${' '.repeat(Math.max(0, errorLocation.column - 2))} ^ ${message}`); + return frame.join('\n'); +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 772f803866403..38346b9774894 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -19,7 +19,7 @@ import path from 'path'; import * as playwrightLibrary from 'playwright-core'; import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringifyForceASCII, asLocatorDescription, renderTitleForCall, getActionGroup } from 'playwright-core/lib/utils'; - +import { buildErrorContext } from './errorContext'; import { currentTestInfo } from './common/globals'; import { rootTestType } from './common/testType'; import { createCustomMessageHandler, runDaemonForBrowser } from './mcp/test/browserBackend'; @@ -733,22 +733,22 @@ class ArtifactsRecorder { if (context) await this._takePageSnapshot(context); - if (this._pageSnapshot && this._testInfo.errors.length > 0 && !this._testInfo.attachments.some(a => a.name === 'error-context')) { - const lines = [ - '# Page snapshot', - '', - '```yaml', - this._pageSnapshot, - '```', - ]; - const filePath = this._testInfo.outputPath('error-context.md'); - await fs.promises.writeFile(filePath, lines.join('\n'), 'utf8'); - - this._testInfo._attach({ - name: 'error-context', - contentType: 'text/markdown', - path: filePath, - }, undefined); + if (this._testInfo.errors.length > 0) { + const errorContextContent = buildErrorContext({ + titlePath: this._testInfo.titlePath, + location: { file: this._testInfo.file, line: this._testInfo.line, column: this._testInfo.column }, + errors: this._testInfo.errors, + pageSnapshot: this._pageSnapshot, + }); + if (errorContextContent) { + const filePath = this._testInfo.outputPath('error-context.md'); + await fs.promises.writeFile(filePath, errorContextContent, 'utf8'); + this._testInfo._attach({ + name: 'error-context', + contentType: 'text/markdown', + path: filePath, + }, undefined); + } } } diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index a93bcd1f82bae..36d65c4b7cc97 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -23,10 +23,7 @@ import type { Language } from '@isomorphic/locatorGenerators'; import { CopyToClipboardTextButton } from './copyToClipboard'; import { useAsyncMemo } from '@web/uiUtils'; import { attachmentURL } from './attachmentsTab'; -import { copyPrompt, stripAnsiEscapes } from '@web/shared/prompts'; import { MetadataWithCommitInfo } from '@testIsomorphic/types'; -import { calculateSha1 } from './sourceTab'; -import type { StackFrame } from '@protocol/channels'; import { useTraceModel } from './traceModelContext'; const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => { @@ -92,48 +89,18 @@ export const ErrorsTab: React.FunctionComponent<{ testRunMetadata: MetadataWithCommitInfo | undefined, }> = ({ errorsModel, sdkLanguage, revealInSource, wallTime, testRunMetadata }) => { const model = useTraceModel(); - const errorContext = useAsyncMemo(async () => { + + const prompt = useAsyncMemo(async () => { const attachment = model?.attachments.find(a => a.name === 'error-context'); if (!attachment) - return; - return await fetch(attachmentURL(model, attachment)).then(r => r.text()); - }, [model], undefined); - - const buildCodeFrame = React.useCallback(async (error: ErrorDescription) => { - const location = error.stack?.[0]; - if (!location) - return; - - let response = model ? await fetch(model.createRelativeUrl(`sha1/src@${await calculateSha1(location.file)}.txt`)) : undefined; - if (!response || response.status === 404) - response = await fetch(`file?path=${encodeURIComponent(location.file)}`); - if (response.status >= 400) - return; - - const source = await response.text(); - - return codeFrame({ - source, - message: stripAnsiEscapes(error.message).split('\n')[0] || undefined, - location, - linesAbove: 100, - linesBelow: 100, - }); - }, [model]); - - const prompt = useAsyncMemo( - () => copyPrompt( - { - testInfo: model?.title ?? '', - metadata: testRunMetadata, - errorContext, - errors: model?.errorDescriptors ?? [], - buildCodeFrame - } - ), - [errorContext, testRunMetadata, model, buildCodeFrame], - undefined - ); + return undefined; + let text = await fetch(attachmentURL(model, attachment)).then(r => r.text()); + if (!text) + return undefined; + if (testRunMetadata?.gitDiff) + text += '\n\n# Local changes\n\n```diff\n' + testRunMetadata.gitDiff + '\n```'; + return text; + }, [model, testRunMetadata], undefined); if (!errorsModel.errors.size) return ; @@ -148,15 +115,3 @@ export const ErrorsTab: React.FunctionComponent<{ })}
; }; - -function codeFrame({ source, message, location, linesAbove, linesBelow }: { source: string, message?: string, location: StackFrame, linesAbove: number, linesBelow: number }): string { - const lines = source.split('\n').slice(); - const start = Math.max(0, location.line - linesAbove - 1); - const end = Math.min(lines.length, location.line + linesBelow); - const scope = lines.slice(start, end); - const lineNumberWidth = String(end).length; - const frame = scope.map((line, index) => `${(start + index + 1) === location.line ? '> ' : ' '}${(start + index + 1).toString().padEnd(lineNumberWidth, ' ')} | ${line}`); - if (message) - frame.splice(location.line - start, 0, `${' '.repeat(lineNumberWidth + 2)} | ${' '.repeat(location.column - 2)} ^ ${message}`); - return frame.join('\n'); -} diff --git a/packages/web/src/shared/prompts.ts b/packages/web/src/shared/prompts.ts deleted file mode 100644 index f6807af40d962..0000000000000 --- a/packages/web/src/shared/prompts.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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 type { MetadataWithCommitInfo } from '@testIsomorphic/types'; - -const fixTestInstructions = ` -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. -`.trimStart(); - -export async function copyPrompt({ - testInfo, - metadata, - errorContext, - - errors, - buildCodeFrame, - stdout, - stderr, -}: { - testInfo: string; - metadata: MetadataWithCommitInfo | undefined; - errorContext: string | undefined; - - errors: ErrorInfo[]; - buildCodeFrame(error: ErrorInfo): Promise; - stdout?: string; - stderr?: string; -}): Promise { - const meaningfulSingleLineErrors = new Set(errors.filter(e => e.message && !e.message.includes('\n')).map(e => e.message!)); - for (const error of errors) { - for (const singleLineError of meaningfulSingleLineErrors.keys()) { - if (error.message?.includes(singleLineError)) - meaningfulSingleLineErrors.delete(singleLineError); - } - } - - const meaningfulErrors = errors.filter(error => { - if (!error.message) - return false; - - // Skip errors that are just a single line - they are likely to already be the error message. - if (!error.message.includes('\n') && !meaningfulSingleLineErrors.has(error.message)) - return false; - - return true; - }); - - if (!meaningfulErrors.length) - return undefined; - - const lines = [ - fixTestInstructions, - `# Test info`, - '', - testInfo, - ]; - - if (stdout) - lines.push('', '# Stdout', '', '```', stripAnsiEscapes(stdout), '```'); - - if (stderr) - lines.push('', '# Stderr', '', '```', stripAnsiEscapes(stderr), '```'); - - lines.push('', '# Error details'); - - for (const error of meaningfulErrors) { - lines.push( - '', - '```', - stripAnsiEscapes(error.message || ''), - '```', - ); - } - - if (errorContext) - lines.push(errorContext); - - const codeFrame = await buildCodeFrame(meaningfulErrors[meaningfulErrors.length - 1]); - if (codeFrame) { - lines.push( - '', - '# Test source', - '', - '```ts', - codeFrame, - '```', - ); - } - - if (metadata?.gitDiff) { - lines.push( - '', - '# Local changes', - '', - '```diff', - metadata.gitDiff, - '```', - ); - } - - return lines.join('\n'); -} - -const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g'); -export function stripAnsiEscapes(str: string): string { - return str.replace(ansiRegex, ''); -} diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index cbdff1d39675b..c9dbecf180d44 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -138,18 +138,22 @@ test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-own-context-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-own-context-passing', ' test-finished-1.png', 'artifacts-passing', ' test-finished-1.png', 'artifacts-persistent-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-persistent-passing', ' test-finished-1.png', 'artifacts-shared-shared-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-shared-shared-passing', ' test-finished-1.png', @@ -157,6 +161,7 @@ test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { ' test-finished-1.png', ' test-finished-2.png', 'artifacts-two-contexts-failing', + ' error-context.md', ' test-failed-1.png', ' test-failed-2.png', ]); @@ -176,14 +181,19 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-own-context-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-persistent-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-shared-shared-failing', + ' error-context.md', ' test-failed-1.png', 'artifacts-two-contexts-failing', + ' error-context.md', ' test-failed-1.png', ' test-failed-2.png', ]); @@ -212,7 +222,10 @@ test('should work with screenshot: on-first-failure', async ({ runInlineTest }, expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'a-fails', + ' error-context.md', ' test-failed-1.png', + 'a-fails-retry1', + ' error-context.md', ]); }); @@ -237,6 +250,7 @@ test('should work with screenshot: only-on-failure & fullPage', async ({ runInli expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-should-fail-and-take-fullPage-screenshots', + ' error-context.md', ' test-failed-1.png', ]); const screenshotFailure = fs.readFileSync( @@ -271,6 +285,7 @@ test('should capture a single screenshot on failure when afterAll fails', async expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'a-passes', + ' error-context.md', ' test-failed-1.png', ]); }); @@ -290,24 +305,29 @@ test('should work with trace: on', async ({ runInlineTest }, testInfo) => { expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing', + ' error-context.md', ' trace.zip', 'artifacts-own-context-passing', ' trace.zip', 'artifacts-passing', ' trace.zip', 'artifacts-persistent-failing', + ' error-context.md', ' trace.zip', 'artifacts-persistent-passing', ' trace.zip', 'artifacts-shared-shared-failing', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-passing', ' trace.zip', 'artifacts-two-contexts', ' trace.zip', 'artifacts-two-contexts-failing', + ' error-context.md', ' trace.zip', ]); }); @@ -326,14 +346,19 @@ test('should work with trace: retain-on-failure', async ({ runInlineTest }, test expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing', + ' error-context.md', ' trace.zip', 'artifacts-persistent-failing', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-failing', + ' error-context.md', ' trace.zip', 'artifacts-two-contexts-failing', + ' error-context.md', ' trace.zip', ]); }); @@ -351,15 +376,30 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf expect(result.failed).toBe(5); expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', + 'artifacts-failing', + ' error-context.md', 'artifacts-failing-retry1', + ' error-context.md', ' trace.zip', + 'artifacts-own-context-failing', + ' error-context.md', 'artifacts-own-context-failing-retry1', + ' error-context.md', ' trace.zip', + 'artifacts-persistent-failing', + ' error-context.md', 'artifacts-persistent-failing-retry1', + ' error-context.md', ' trace.zip', + 'artifacts-shared-shared-failing', + ' error-context.md', 'artifacts-shared-shared-failing-retry1', + ' error-context.md', ' trace.zip', + 'artifacts-two-contexts-failing', + ' error-context.md', 'artifacts-two-contexts-failing-retry1', + ' error-context.md', ' trace.zip', ]); }); @@ -377,25 +417,45 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf expect(result.failed).toBe(5); expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', + 'artifacts-failing', + ' error-context.md', 'artifacts-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-failing-retry2', + ' error-context.md', ' trace.zip', + 'artifacts-own-context-failing', + ' error-context.md', 'artifacts-own-context-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing-retry2', + ' error-context.md', ' trace.zip', + 'artifacts-persistent-failing', + ' error-context.md', 'artifacts-persistent-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-persistent-failing-retry2', + ' error-context.md', ' trace.zip', + 'artifacts-shared-shared-failing', + ' error-context.md', 'artifacts-shared-shared-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-failing-retry2', + ' error-context.md', ' trace.zip', + 'artifacts-two-contexts-failing', + ' error-context.md', 'artifacts-two-contexts-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-two-contexts-failing-retry2', + ' error-context.md', ' trace.zip', ]); }); @@ -414,15 +474,40 @@ test('should work with trace: retain-on-first-failure', async ({ runInlineTest } expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' trace.zip', + 'artifacts-failing-retry1', + ' error-context.md', + 'artifacts-failing-retry2', + ' error-context.md', 'artifacts-own-context-failing', + ' error-context.md', ' trace.zip', + 'artifacts-own-context-failing-retry1', + ' error-context.md', + 'artifacts-own-context-failing-retry2', + ' error-context.md', 'artifacts-persistent-failing', + ' error-context.md', ' trace.zip', + 'artifacts-persistent-failing-retry1', + ' error-context.md', + 'artifacts-persistent-failing-retry2', + ' error-context.md', 'artifacts-shared-shared-failing', + ' error-context.md', ' trace.zip', + 'artifacts-shared-shared-failing-retry1', + ' error-context.md', + 'artifacts-shared-shared-failing-retry2', + ' error-context.md', 'artifacts-two-contexts-failing', + ' error-context.md', ' trace.zip', + 'artifacts-two-contexts-failing-retry1', + ' error-context.md', + 'artifacts-two-contexts-failing-retry2', + ' error-context.md', ]); }); @@ -440,34 +525,49 @@ test('should work with trace: retain-on-failure-and-retries', async ({ runInline expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', + ' error-context.md', ' trace.zip', 'artifacts-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-failing-retry2', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-own-context-failing-retry2', + ' error-context.md', ' trace.zip', 'artifacts-persistent-failing', + ' error-context.md', ' trace.zip', 'artifacts-persistent-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-persistent-failing-retry2', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-failing', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-shared-shared-failing-retry2', + ' error-context.md', ' trace.zip', 'artifacts-two-contexts-failing', + ' error-context.md', ' trace.zip', 'artifacts-two-contexts-failing-retry1', + ' error-context.md', ' trace.zip', 'artifacts-two-contexts-failing-retry2', + ' error-context.md', ' trace.zip', ]); }); diff --git a/tests/playwright-test/playwright.connect.spec.ts b/tests/playwright-test/playwright.connect.spec.ts index 94664a3ae6b0b..71aa32b0cc83a 100644 --- a/tests/playwright-test/playwright.connect.spec.ts +++ b/tests/playwright-test/playwright.connect.spec.ts @@ -167,7 +167,11 @@ test('should print debug log when failed to connect', async ({ runInlineTest }) expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.output).toContain('b-debug-log-string'); - expect(result.results[0].attachments).toEqual([]); + expect(result.results[0].attachments).toEqual([{ + name: 'error-context', + contentType: 'text/markdown', + path: expect.stringContaining('error-context.md'), + }]); }); test('should record trace', async ({ runInlineTest }) => { @@ -224,6 +228,7 @@ test('should record trace', async ({ runInlineTest }) => { ' Fixture "page"', ' Fixture "context"', ' Close context', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index e771195992e05..78ebd785be167 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -129,6 +129,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' Fixture "context"', ' Close context', ' Fixture "request"', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); @@ -657,6 +658,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo ' Fixture "page"', ' Fixture "context"', ' Close context', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); @@ -985,6 +987,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest }, ' step in barPage teardown', ' Close context', 'Attach "error-context"', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); @@ -1072,6 +1075,7 @@ test('should attribute worker fixture teardown to the right test', async ({ runI expect(trace2.model.renderActionTree()).toEqual([ 'Before Hooks', 'After Hooks', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "foo"', ' step in foo teardown', @@ -1221,6 +1225,7 @@ test('should not corrupt actions when no library trace is present', async ({ run 'After Hooks', ' Fixture "foo"', ' Expect "toBe"', + 'Attach "error-context"', 'Worker Cleanup', ]); }); @@ -1251,6 +1256,7 @@ test('should record trace for manually created context in a failed test', async 'Set content', 'Expect "toBe"', 'After Hooks', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); @@ -1338,6 +1344,7 @@ test('should record trace after fixture teardown timeout', { 'Evaluate', 'After Hooks', ' Fixture "fixture"', + 'Attach "error-context"', 'Worker Cleanup', ' Fixture "browser"', ]); diff --git a/tests/playwright-test/reporter-line.spec.ts b/tests/playwright-test/reporter-line.spec.ts index f999b39918e88..1a686bf42e45a 100644 --- a/tests/playwright-test/reporter-line.spec.ts +++ b/tests/playwright-test/reporter-line.spec.ts @@ -308,6 +308,8 @@ Running 1 test using 1 worker 9 | at ${test.info().outputPath('a.test.ts')}:7:17 + Error Context: test-results/a-foo/error-context.md + 1 interrupted a.test.ts:3:13 › foo ─────────────────────────────────────────────────────────────────────────── diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts index c48893d9e7ad7..514563bcc61b9 100644 --- a/tests/playwright-test/reporter-list.spec.ts +++ b/tests/playwright-test/reporter-list.spec.ts @@ -488,6 +488,8 @@ Running 1 test using 1 worker 9 | at ${test.info().outputPath('a.test.ts')}:7:17 + Error Context: test-results/a-foo/error-context.md + 1 interrupted a.test.ts:3:13 › foo ─────────────────────────────────────────────────────────────────────────── `);