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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/html-reporter/src/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[*]
@playwright/experimental-ct-react
@web/**
@isomorphic/**

[chip.spec.tsx]
***
Expand Down
31 changes: 17 additions & 14 deletions packages/html-reporter/src/testResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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 <div className='test-result'>
{!!errors.length && <AutoChip header='Errors'>
Expand Down
1 change: 1 addition & 0 deletions packages/html-reporter/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 11 additions & 1 deletion packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export class Response {
const content: (TextContent | ImageContent)[] = [
{
type: 'text',
text: redactText(text.join('\n')),
text: sanitizeUnicode(redactText(text.join('\n'))),
}
];

Expand Down Expand Up @@ -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<string, string> {
const sections = new Map<string, string>();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ common/
[index.ts]
@testIsomorphic/**
./prompt.ts
./errorContext.ts
./worker/testTracing.ts
./mcp/sdk/
./mcp/test/
Expand All @@ -18,4 +19,3 @@ common/
**

[errorContext.ts]
./transform/babelBundle.ts
121 changes: 121 additions & 0 deletions packages/playwright/src/errorContext.ts
Original file line number Diff line number Diff line change
@@ -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');
}
34 changes: 17 additions & 17 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/playwright/src/reporters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
65 changes: 10 additions & 55 deletions packages/trace-viewer/src/ui/errorsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 <PlaceholderPanel text='No errors' />;
Expand All @@ -148,15 +115,3 @@ export const ErrorsTab: React.FunctionComponent<{
})}
</div>;
};

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');
}
2 changes: 1 addition & 1 deletion packages/trace-viewer/src/ui/filmStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && <div className='film-strip-hover-title'>{renderAction(previewPoint.action, previewPoint)}</div>}
{previewImage && previewSize && <div style={{ width: previewSize.width, height: previewSize.height }}>
<img src={model.createRelativeUrl(`sha1/${previewImage.sha1}`)} width={previewSize.width} height={previewSize.height} />
</div>}
{previewPoint.action && <div className='film-strip-hover-title'>{renderAction(previewPoint.action, previewPoint)}</div>}
</div>
}
</div>;
Expand Down
Loading
Loading