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-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts
index 81b959af61236..d686353c66c9a 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,16 @@ 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 {
+ if ((String.prototype as any).toWellFormed)
+ return text.toWellFormed();
+ return text;
+}
+
function parseSections(text: string): Map {
const sections = new Map();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
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/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) {
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/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={
-
+
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/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/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/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'] },
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();
+ });
+});
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-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/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 ───────────────────────────────────────────────────────────────────────────
`);
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();