chore: render error context to copy prompt (#36123)

This commit is contained in:
Simon Knott 2025-05-30 15:43:55 +02:00 committed by GitHub
parent c587492ca5
commit 43a086a8de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 255 additions and 53 deletions

11
package-lock.json generated
View File

@ -1672,6 +1672,13 @@
"node": ">=10"
}
},
"node_modules/@types/babel__code-frame": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz",
"integrity": "sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -8765,7 +8772,11 @@
"packages/trace-viewer": {
"version": "0.0.0",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/babel__code-frame": "^7.0.6"
}
},
"packages/web": {

View File

@ -144,6 +144,7 @@ const TestCaseViewLoader: React.FC<{
return <div className='test-case-column'>
<TestCaseView
projectNames={report.json().projectNames}
testRunMetadata={report.json().metadata}
next={next}
prev={prev}
test={test}

View File

@ -66,7 +66,7 @@ const testCase: TestCase = {
};
test('should render test case', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={testCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await expect(component.getByText('Hidden annotation')).toBeHidden();
await component.getByText('Annotations').click();
@ -82,7 +82,7 @@ test('should render test case', async ({ mount }) => {
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={testCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await component.getByText('Annotation text', { exact: false }).first().hover();
await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible();
@ -113,7 +113,7 @@ const annotationLinkRenderingTestCase: TestCase = {
};
test('should correctly render links in annotations', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={annotationLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
await expect(firstLink).toBeVisible();
@ -188,7 +188,7 @@ const testCaseSummary: TestCaseSummary = {
test('should correctly render links in attachments', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
await component.getByText('first attachment').click();
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
await expect(body).toBeVisible();
@ -201,7 +201,7 @@ test('should correctly render links in attachments', async ({ mount }) => {
});
test('should correctly render links in attachment name', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={attachmentLinkRenderingTestCase} prev={undefined} next={undefined} run={0}></TestCaseView>);
const link = component.getByText('attachment with inline link').locator('a');
await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284');
@ -211,7 +211,7 @@ test('should correctly render links in attachment name', async ({ mount }) => {
});
test('should correctly render prev and next', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={testCaseSummary} next={testCaseSummary} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={attachmentLinkRenderingTestCase} prev={testCaseSummary} next={testCaseSummary} run={0}></TestCaseView>);
await expect(component).toMatchAriaSnapshot(`
- text: group
- link "« previous"
@ -226,7 +226,7 @@ const testCaseWithTwoAttempts: TestCase = {
results: [
{
...result,
errors: ['Error message'],
errors: [{ message: 'Error message' }],
status: 'failed',
duration: 50,
},
@ -239,7 +239,7 @@ const testCaseWithTwoAttempts: TestCase = {
};
test('total duration is selected run duration', async ({ mount, page }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCaseWithTwoAttempts} prev={undefined} next={undefined} run={0}></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} testRunMetadata={{}} test={testCaseWithTwoAttempts} prev={undefined} next={undefined} run={0}></TestCaseView>);
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms"
- tablist:

View File

@ -29,14 +29,16 @@ import { hashStringToInt, msToString } from './utils';
import { clsx } from '@web/uiUtils';
import { CopyToClipboardContainer } from './copyToClipboard';
import { HeaderView } from './headerView';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
export const TestCaseView: React.FC<{
projectNames: string[],
test: TestCase,
testRunMetadata: MetadataWithCommitInfo | undefined,
next: TestCaseSummary | undefined,
prev: TestCaseSummary | undefined,
run: number,
}> = ({ projectNames, test, run, next, prev }) => {
}> = ({ projectNames, test, testRunMetadata, run, next, prev }) => {
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
const searchParams = React.useContext(SearchParamsContext);
@ -83,7 +85,7 @@ export const TestCaseView: React.FC<{
{!!visibleAnnotations.length && <AutoChip header='Annotations' dataTestId='test-case-annotations'>
{visibleAnnotations.map((annotation, index) => <TestCaseAnnotationView key={index} annotation={annotation} />)}
</AutoChip>}
<TestResultView test={test!} result={result} />
<TestResultView test={test!} result={result} testRunMetadata={testRunMetadata} />
</>;
},
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />

View File

@ -19,8 +19,6 @@ import * as React from 'react';
import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import { TestAttachment } from './types';
import { fixTestInstructions } from '@web/prompts';
export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<{ code: string; testId?: string; }>) => {
const html = React.useMemo(() => ansiErrorToHtml(code), [code]);
@ -32,14 +30,12 @@ export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<
);
};
export const PromptButton: React.FC<{ context?: TestAttachment }> = ({ context }) => {
export const PromptButton: React.FC<{ prompt: string }> = ({ prompt }) => {
const [copied, setCopied] = React.useState(false);
return <button
className='button'
style={{ minWidth: 100 }}
onClick={async () => {
const contextText = context?.path ? await fetch(context.path!).then(r => r.text()) : context?.body;
const prompt = fixTestInstructions + contextText; // TODO in next PR: enrich with test location, error details and source code.
await navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => {

View File

@ -27,6 +27,9 @@ import { ImageDiffView } from '@web/shared/imageDiffView';
import { CodeSnippet, PromptButton, TestScreenshotErrorView } from './testErrorView';
import * as icons from './icons';
import './testResultView.css';
import { useAsyncMemo } from '@web/uiUtils';
import { copyPrompt } from '@web/shared/prompts';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
interface ImageDiffWithAnchors extends ImageDiff {
anchors: string[];
@ -70,7 +73,8 @@ function groupImageDiffs(screenshots: Set<TestAttachment>, result: TestResult):
export const TestResultView: React.FC<{
test: TestCase,
result: TestResult,
}> = ({ test, result }) => {
testRunMetadata: MetadataWithCommitInfo | undefined,
}> = ({ test, result, testRunMetadata }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors, errorContext } = React.useMemo(() => {
const attachments = result.attachments.filter(a => !a.name.startsWith('_'));
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
@ -82,15 +86,30 @@ export const TestResultView: React.FC<{
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`);
const diffs = groupImageDiffs(screenshots, result);
const errors = classifyErrors(result.errors, diffs);
const errors = classifyErrors(result.errors.map(e => e.message), diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors, errorContext };
}, [result]);
const prompt = useAsyncMemo(async () => {
return await copyPrompt({
testInfo: [
`- Name: ${test.path.join(' >> ')} >> ${test.title}`,
`- Location: ${test.location.file}:${test.location.line}:${test.location.column}`
].join('\n'),
metadata: testRunMetadata,
errorContext: errorContext?.path ? await fetch(errorContext.path!).then(r => r.text()) : errorContext?.body,
errors: result.errors,
buildCodeFrame: async error => error.codeframe,
});
}, [test, errorContext, testRunMetadata, result], undefined);
return <div className='test-result'>
{!!errors.length && <AutoChip header='Errors'>
<div style={{ position: 'absolute', right: '16px', padding: '10px', zIndex: 1 }}>
<PromptButton context={errorContext} />
</div>
{prompt && (
<div style={{ position: 'absolute', right: '16px', padding: '10px', zIndex: 1 }}>
<PromptButton prompt={prompt} />
</div>
)}
{errors.map((error, index) => {
if (error.type === 'screenshot')
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;

View File

@ -94,7 +94,7 @@ export type TestResult = {
startTime: string;
duration: number;
steps: TestStep[];
errors: string[];
errors: { message: string, codeframe?: string }[];
attachments: TestAttachment[];
status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted';
annotations: TestAnnotation[];

View File

@ -31,7 +31,7 @@ import { resolveReporterOutputPath, stripAnsiEscapes, stepTitle } from '../util'
import type { ReporterV2 } from './reporterV2';
import type { HtmlReporterOptions as HtmlReporterConfigOptions, Metadata, TestAnnotation } from '../../types/test';
import type * as api from '../../types/testReporter';
import type { HTMLReport, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types';
import type { HTMLReport, Location, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types';
import type { ZipFile } from 'playwright-core/lib/zipBundle';
import type { TransformCallback } from 'stream';
import type { TestStepCategory } from '../util';
@ -511,7 +511,12 @@ class HtmlBuilder {
startTime: result.startTime.toISOString(),
retry: result.retry,
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s, result)),
errors: formatResultFailure(internalScreen, test, result, '').map(error => error.message),
errors: formatResultFailure(internalScreen, test, result, '').map(error => {
return {
message: error.message,
codeframe: error.location ? createErrorCodeframe(error.message, error.location) : undefined
};
}),
status: result.status,
annotations: this._serializeAnnotations(result.annotations),
attachments: this._serializeAttachments([
@ -675,4 +680,29 @@ function createSnippets(stepsInFile: MultiMap<string, TestStep>) {
}
}
function createErrorCodeframe(message: string, location: Location) {
let source: string;
try {
source = fs.readFileSync(location.file, 'utf-8') + '\n//';
} catch (e) {
return;
}
return codeFrameColumns(
source,
{
start: {
line: location.line,
column: location.column,
},
},
{
highlightCode: false,
linesAbove: 100,
linesBelow: 100,
message: stripAnsiEscapes(message).split('\n')[0] || undefined,
}
);
}
export default HtmlReporter;

View File

@ -4,6 +4,10 @@
"version": "0.0.0",
"type": "module",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"yaml": "^2.6.0"
},
"devDependencies": {
"@types/babel__code-frame": "^7.0.6"
}
}

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import { codeFrameColumns } from '@babel/code-frame';
import { ErrorMessage } from '@web/components/errorMessage';
import * as React from 'react';
import type * as modelUtil from './modelUtil';
@ -23,7 +24,9 @@ import type { Language } from '@isomorphic/locatorGenerators';
import { CopyToClipboardTextButton } from './copyToClipboard';
import { useAsyncMemo } from '@web/uiUtils';
import { attachmentURL } from './attachmentsTab';
import { fixTestInstructions } from '@web/prompts';
import { copyPrompt, stripAnsiEscapes } from '@web/shared/prompts';
import { MetadataWithCommitInfo } from '@testIsomorphic/types';
import { calculateSha1 } from './sourceTab';
const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => {
return (
@ -86,7 +89,8 @@ export const ErrorsTab: React.FunctionComponent<{
wallTime: number,
sdkLanguage: Language,
revealInSource: (error: modelUtil.ErrorDescription) => void,
}> = ({ errorsModel, model, sdkLanguage, revealInSource, wallTime }) => {
testRunMetadata: MetadataWithCommitInfo | undefined,
}> = ({ errorsModel, model, sdkLanguage, revealInSource, wallTime, testRunMetadata }) => {
const errorContext = useAsyncMemo(async () => {
const attachment = model?.attachments.find(a => a.name === 'error-context');
if (!attachment)
@ -94,7 +98,49 @@ export const ErrorsTab: React.FunctionComponent<{
return await fetch(attachmentURL(attachment)).then(r => r.text());
}, [model], undefined);
const prompt = fixTestInstructions + (errorContext ?? ''); // TODO in next PR: enrich with test location, error details and source code, similar to errorContext.ts
const buildCodeFrame = React.useCallback(async (error: modelUtil.ErrorDescription) => {
const location = error.stack?.[0];
if (!location)
return;
let response = await fetch(`sha1/src@${await calculateSha1(location.file)}.txt`);
if (response.status === 404)
response = await fetch(`file?path=${encodeURIComponent(location.file)}`);
if (response.status >= 400)
return;
const source = await response.text();
return codeFrameColumns(
source,
{
start: {
line: location.line,
column: location.column,
},
},
{
highlightCode: false,
linesAbove: 100,
linesBelow: 100,
message: stripAnsiEscapes(error.message).split('\n')[0] || undefined,
}
);
}, []);
const prompt = useAsyncMemo(
() => copyPrompt(
{
testInfo: model?.title ?? '',
metadata: testRunMetadata,
errorContext,
errors: model?.errorDescriptors ?? [],
buildCodeFrame
}
),
[errorContext, testRunMetadata, model, buildCodeFrame],
undefined
);
if (!errorsModel.errors.size)
return <PlaceholderPanel text='No errors' />;

View File

@ -43,6 +43,7 @@ import type { UITestStatus } from './testUtils';
import type { AfterActionTraceEventAttachment } from '@trace/trace';
import type { HighlightedElement } from './snapshotTab';
import type { TestAnnotation } from '@playwright/test';
import { MetadataWithCommitInfo } from '@testIsomorphic/types';
export const Workbench: React.FunctionComponent<{
model?: modelUtil.MultiTraceModel,
@ -56,7 +57,8 @@ export const Workbench: React.FunctionComponent<{
inert?: boolean,
onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => {
testRunMetadata?: MetadataWithCommitInfo,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource, testRunMetadata }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<modelUtil.ErrorDescription | undefined>(undefined);
const [revealedAttachment, setRevealedAttachment] = React.useState<[attachment: AfterActionTraceEventAttachment, renderCounter: number] | undefined>(undefined);
@ -190,7 +192,7 @@ export const Workbench: React.FunctionComponent<{
id: 'errors',
title: 'Errors',
errorCount: errorsModel.errors.size,
render: () => <ErrorsTab errorsModel={errorsModel} model={model} sdkLanguage={sdkLanguage} revealInSource={error => {
render: () => <ErrorsTab errorsModel={errorsModel} model={model} testRunMetadata={testRunMetadata} sdkLanguage={sdkLanguage} revealInSource={error => {
if (error.action)
setSelectedAction(error.action);
else

View File

@ -1,23 +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.
*/
export 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();

View File

@ -0,0 +1,114 @@
/**
* 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<ErrorInfo extends { message: string }>({
testInfo,
metadata,
errorContext,
errors,
buildCodeFrame,
}: {
testInfo: string;
metadata: MetadataWithCommitInfo | undefined;
errorContext: string | undefined;
errors: ErrorInfo[];
buildCodeFrame(error: ErrorInfo): Promise<string | undefined>;
}) {
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,
'',
'# 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, '');
}

View File

@ -2874,7 +2874,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(page.locator('.tree-item', { hasText: 'stdout' })).toHaveCount(1);
});
test.fixme('should include diff in AI prompt', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include diff in AI prompt', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `export default {}`,

View File

@ -555,7 +555,7 @@ test('skipped steps should have an indicator', async ({ runUITest }) => {
await expect(skippedMarker).toHaveAccessibleName('skipped');
});
test.fixme('should show copy prompt button in errors tab', async ({ runUITest }) => {
test('should show copy prompt button in errors tab', async ({ runUITest }) => {
const { page } = await runUITest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';