refactor: store copy prompt contents in attachment (#34995)

This commit is contained in:
Simon Knott 2025-03-04 17:20:36 +01:00 committed by GitHub
parent 5f75e69279
commit 88623ae3c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 194 additions and 340 deletions

View File

@ -1,29 +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 * as React from 'react';
import type { HTMLReport } from './types';
const HTMLReportContext = React.createContext<HTMLReport | undefined>(undefined);
export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) {
return <HTMLReportContext.Provider value={report}>{children}</HTMLReportContext.Provider>;
}
export function useHTMLReport() {
return React.useContext(HTMLReportContext);
}

View File

@ -26,7 +26,6 @@ import './reportView.css';
import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
import { HTMLReportContextProvider } from './reportContext';
declare global {
interface Window {
@ -73,7 +72,7 @@ export const ReportView: React.FC<{
return result;
}, [report, filter]);
return <HTMLReportContextProvider report={report?.json()}><div className='htmlreport vbox px-4 pb-4'>
return <div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<Route predicate={testFilesRoutePredicate}>
@ -89,7 +88,7 @@ export const ReportView: React.FC<{
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route>
</main>
</div></HTMLReportContextProvider>;
</div>;
};
const TestCaseViewLoader: React.FC<{

View File

@ -19,21 +19,19 @@ import * as React from 'react';
import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import type { TestResult } from './types';
import { fixTestPrompt } from '@web/components/prompts';
import { useHTMLReport } from './reportContext';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
export const TestErrorView: React.FC<{
error: string;
testId?: string;
result?: TestResult
}> = ({ error, testId, result }) => {
prompt?: string;
}> = ({ error, testId, prompt }) => {
return (
<CodeSnippet code={error} testId={testId}>
<div style={{ float: 'right', margin: 10 }}>
<PromptButton error={error} result={result} />
</div>
{prompt && (
<div style={{ float: 'right', margin: 10 }}>
<PromptButton prompt={prompt} />
</div>
)}
</CodeSnippet>
);
};
@ -48,24 +46,8 @@ export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<
);
};
const PromptButton: React.FC<{
error: string;
result?: TestResult;
}> = ({ error, result }) => {
const report = useHTMLReport();
const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined;
const pageSnapshot = result?.attachments.find(a => a.name === 'pageSnapshot')?.body;
const prompt = React.useMemo(() => fixTestPrompt(
error,
commitInfo?.gitDiff,
pageSnapshot
), [commitInfo, pageSnapshot, error]);
const PromptButton: React.FC<{ prompt: string }> = ({ prompt }) => {
const [copied, setCopied] = React.useState(false);
if (!pageSnapshot)
return;
return <button
className='button'
style={{ minWidth: 100 }}

View File

@ -81,7 +81,7 @@ 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, diffs, result.attachments);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
}, [result]);
@ -90,7 +90,7 @@ export const TestResultView: React.FC<{
{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>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!} result={result}></TestErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!} prompt={error.prompt}></TestErrorView>;
})}
</AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'>
@ -144,8 +144,8 @@ export const TestResultView: React.FC<{
</div>;
};
function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
return testErrors.map(error => {
function classifyErrors(testErrors: string[], diffs: ImageDiff[], attachments: TestAttachment[]) {
return testErrors.map((error, i) => {
const firstLine = error.split('\n')[0];
if (firstLine.includes('toHaveScreenshot') || firstLine.includes('toMatchSnapshot')) {
const matchingDiff = diffs.find(diff => {
@ -164,7 +164,9 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
return { type: 'screenshot', diff: matchingDiff, errorPrefix, errorSuffix };
}
}
return { type: 'regular', error };
const prompt = attachments.find(a => a.name === `_prompt-${i}`)?.body;
return { type: 'regular', error, prompt };
});
}

View File

@ -20,7 +20,7 @@ import path from 'path';
import { getPackageJsonPath, mergeObjects } from '../util';
import type { Config, Fixtures, Project, ReporterDescription } from '../../types/test';
import type { Config, Fixtures, Metadata, Project, ReporterDescription } from '../../types/test';
import type { TestRunnerPluginRegistration } from '../plugins';
import type { Matcher } from '../util';
import type { ConfigCLIOverrides } from './ipc';
@ -65,7 +65,7 @@ export class FullConfigInternal {
globalSetups: string[] = [];
globalTeardowns: string[] = [];
constructor(location: ConfigLocation, userConfig: Config, configCLIOverrides: ConfigCLIOverrides) {
constructor(location: ConfigLocation, userConfig: Config, configCLIOverrides: ConfigCLIOverrides, metadata?: Metadata) {
if (configCLIOverrides.projects && userConfig.projects)
throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`);
@ -98,7 +98,7 @@ export class FullConfigInternal {
grep: takeFirst(userConfig.grep, defaultGrep),
grepInvert: takeFirst(userConfig.grepInvert, null),
maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0),
metadata: userConfig.metadata,
metadata: metadata ?? userConfig.metadata,
preserveOutput: takeFirst(userConfig.preserveOutput, 'always'),
reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]),
reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 300_000 /* 5 minutes */ }),

View File

@ -91,7 +91,7 @@ export const defineConfig = (...configs: any[]) => {
export async function deserializeConfig(data: SerializedConfig): Promise<FullConfigInternal> {
if (data.compilationCache)
addToCompilationCache(data.compilationCache);
return await loadConfig(data.location, data.configCLIOverrides);
return await loadConfig(data.location, data.configCLIOverrides, undefined, data.metadata ? JSON.parse(data.metadata) : undefined);
}
async function loadUserConfig(location: ConfigLocation): Promise<Config> {
@ -101,7 +101,7 @@ async function loadUserConfig(location: ConfigLocation): Promise<Config> {
return object as Config;
}
export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> {
export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise<FullConfigInternal> {
// 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache.
setSingleTSConfig(overrides?.tsconfig);
await configureESMLoader();
@ -109,7 +109,7 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
// 2. Load and validate playwright config.
const userConfig = await loadUserConfig(location);
validateConfig(location.resolvedConfigFile || '<default config>', userConfig);
const fullConfig = new FullConfigInternal(location, userConfig, overrides || {});
const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}, metadata);
fullConfig.defineConfigWasUsed = !!(userConfig as any)[kDefineConfigWasUsed];
if (ignoreProjectDependencies) {
for (const project of fullConfig.projects) {

View File

@ -51,6 +51,7 @@ export type SerializedConfig = {
location: ConfigLocation;
configCLIOverrides: ConfigCLIOverrides;
compilationCache?: SerializedCompilationCache;
metadata?: string;
};
export type ProcessInitParams = {
@ -147,6 +148,11 @@ export function serializeConfig(config: FullConfigInternal, passCompilationCache
configCLIOverrides: config.configCLIOverrides,
compilationCache: passCompilationCache ? serializeCompilationCache() : undefined,
};
try {
result.metadata = JSON.stringify(config.config.metadata);
} catch (error) {}
return result;
}

View File

@ -22,7 +22,9 @@ import { setBoxedStackPrefixes, asLocator, createGuid, currentZone, debugMode, i
import { currentTestInfo } from './common/globals';
import { rootTestType } from './common/testType';
import { stripAnsiEscapes } from './util';
import type { MetadataWithCommitInfo } from './isomorphic/types';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
import type { ContextReuseMode } from './common/config';
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
@ -249,8 +251,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
// Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout);
const pageSnapshot = process.env.PLAYWRIGHT_COPY_PROMPT ? 'on' : 'off';
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, pageSnapshot);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = [];
@ -321,7 +322,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
clientInstrumentation.removeListener(csiListener);
await artifactsRecorder.didFinishTest();
}, { auto: 'all-hooks-included', title: 'trace recording', box: true, timeout: 0 } as any],
_contextFactory: [async ({ browser, video, _reuseContext, _combinedContextOptions /** mitigate dep-via-auto lack of traceability */ }, use, testInfo) => {
@ -617,10 +617,10 @@ class ArtifactsRecorder {
private _reusedContexts = new Set<BrowserContext>();
private _startedCollectingArtifacts: symbol;
private _pageSnapshotRecorder: SnapshotRecorder;
private _screenshotRecorder: SnapshotRecorder;
private _pageSnapshot: string | undefined;
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption, pageSnapshot: SnapshotRecorderMode) {
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption) {
this._playwright = playwright;
this._artifactsDir = artifactsDir;
const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
@ -629,11 +629,6 @@ class ArtifactsRecorder {
this._screenshotRecorder = new SnapshotRecorder(this, normalizeScreenshotMode(screenshot), 'screenshot', 'image/png', '.png', async (page, path) => {
await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' });
});
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/yaml', '.aria.yml', async (page, path) => {
const ariaSnapshot = await page.locator('body').ariaSnapshot({ timeout: 5000 });
await fs.promises.writeFile(path, ariaSnapshot);
});
}
async willStartTest(testInfo: TestInfoImpl) {
@ -641,7 +636,6 @@ class ArtifactsRecorder {
testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction();
this._screenshotRecorder.fixOrdinal();
this._pageSnapshotRecorder.fixOrdinal();
// Process existing contexts.
await Promise.all(this._playwright._allContexts().map(async context => {
@ -668,7 +662,14 @@ class ArtifactsRecorder {
await this._stopTracing(context.tracing);
await this._screenshotRecorder.captureTemporary(context);
await this._pageSnapshotRecorder.captureTemporary(context);
if (!process.env.PLAYWRIGHT_NO_COPY_PROMPT && this._testInfo.errors.length > 0) {
try {
const page = context.pages()[0];
// TODO: maybe capture snapshot when the error is created, so it's from the right page and right time
this._pageSnapshot ??= await page?.locator('body').ariaSnapshot({ timeout: 5000 });
} catch {}
}
}
async didCreateRequestContext(context: APIRequestContext) {
@ -683,7 +684,6 @@ class ArtifactsRecorder {
async didFinishTestFunction() {
await this._screenshotRecorder.maybeCapture();
await this._pageSnapshotRecorder.maybeCapture();
}
async didFinishTest() {
@ -701,7 +701,71 @@ class ArtifactsRecorder {
})));
await this._screenshotRecorder.persistTemporary();
await this._pageSnapshotRecorder.persistTemporary();
await this._attachErrorPrompts();
}
private async _attachErrorPrompts() {
if (process.env.PLAYWRIGHT_NO_COPY_PROMPT)
return;
if (this._testInfo.errors.length === 0)
return;
const testSources = await fs.promises.readFile(this._testInfo.file, 'utf-8');
for (const [index, error] of this._testInfo.errors.entries()) {
if (this._testInfo.attachments.find(a => a.name === `_prompt-${index}`))
continue;
const metadata = this._testInfo.config.metadata as MetadataWithCommitInfo;
const promptParts = [
`My Playwright test failed.`,
`Explain why, be concise, respect Playwright best practices.`,
'',
`Failed test: ${this._testInfo.titlePath.join(' >> ')}`,
'',
'Error:',
'',
'```',
stripAnsiEscapes(error.stack || error.message || ''),
'```',
];
if (this._pageSnapshot) {
promptParts.push(
'',
'Page snapshot:',
'```yaml',
this._pageSnapshot,
'```',
);
}
if (metadata.gitDiff) {
promptParts.push(
'',
'Local changes:',
'```diff',
metadata.gitDiff,
'```',
);
}
promptParts.push(
'',
'Test file:',
'```ts',
`// ${this._testInfo.file}`,
testSources,
'```',
);
this._testInfo._attach({
name: `_prompt-${index}`,
contentType: 'text/markdown',
body: Buffer.from(promptParts.join('\n')),
}, undefined);
}
}
private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View File

@ -21,7 +21,7 @@ import { parseStackFrame } from 'playwright-core/lib/utils';
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
import { colors as realColors, noColors } from 'playwright-core/lib/utils';
import { resolveReporterOutputPath } from '../util';
import { ansiRegex, resolveReporterOutputPath, stripAnsiEscapes } from '../util';
import { getEastAsianWidth } from '../utilsBundle';
import type { ReporterV2 } from './reporterV2';
@ -357,6 +357,8 @@ export function formatFailure(screen: Screen, config: FullConfig, test: TestCase
resultLines.push(...errors.map(error => '\n' + error.message));
for (let i = 0; i < result.attachments.length; ++i) {
const attachment = result.attachments[i];
if (attachment.name.startsWith('_'))
continue;
const hasPrintableContent = attachment.contentType.startsWith('text/');
if (!attachment.path && !hasPrintableContent)
continue;
@ -557,11 +559,6 @@ export function prepareErrorStack(stack: string): {
return { message, stackLines, location };
}
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, '');
}
function characterWidth(c: string) {
return getEastAsianWidth.eastAsianWidth(c.codePointAt(0)!);
}

View File

@ -19,7 +19,8 @@ import path from 'path';
import { noColors } from 'playwright-core/lib/utils';
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
import { TerminalReporter, formatResultFailure, formatRetry, stripAnsiEscapes } from './base';
import { TerminalReporter, formatResultFailure, formatRetry } from './base';
import { stripAnsiEscapes } from '../util';
import type { FullResult, TestCase, TestError } from '../../types/testReporter';

View File

@ -24,9 +24,9 @@ import { open } from 'playwright-core/lib/utilsBundle';
import { mime } from 'playwright-core/lib/utilsBundle';
import { yazl } from 'playwright-core/lib/zipBundle';
import { formatError, formatResultFailure, internalScreen, stripAnsiEscapes } from './base';
import { formatError, formatResultFailure, internalScreen } from './base';
import { codeFrameColumns } from '../transform/babelBundle';
import { resolveReporterOutputPath } from '../util';
import { resolveReporterOutputPath, stripAnsiEscapes } from '../util';
import type { ReporterV2 } from './reporterV2';
import type { Metadata } from '../../types/test';
@ -449,17 +449,6 @@ class HtmlBuilder {
return a;
}
if (a.name === 'pageSnapshot') {
try {
const body = fs.readFileSync(a.path!, { encoding: 'utf-8' });
return {
name: 'pageSnapshot',
contentType: a.contentType,
body,
};
} catch {}
}
if (a.path) {
let fileName = a.path;
try {

View File

@ -19,7 +19,8 @@ import path from 'path';
import { getAsBooleanFromENV } from 'playwright-core/lib/utils';
import { formatFailure, nonTerminalScreen, resolveOutputFile, stripAnsiEscapes } from './base';
import { formatFailure, nonTerminalScreen, resolveOutputFile } from './base';
import { stripAnsiEscapes } from '../util';
import type { ReporterV2 } from './reporterV2';
import type { FullConfig, FullResult, Suite, TestCase } from '../../types/testReporter';

View File

@ -17,7 +17,8 @@
import { getAsBooleanFromENV } from 'playwright-core/lib/utils';
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
import { TerminalReporter, stepSuffix, stripAnsiEscapes } from './base';
import { TerminalReporter, stepSuffix } from './base';
import { stripAnsiEscapes } from '../util';
import type { FullResult, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter';

View File

@ -414,3 +414,8 @@ export async function removeDirAndLogToConsole(dir: string) {
} catch {
}
}
export 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

@ -20,86 +20,14 @@ import type * as modelUtil from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel';
import { renderAction } from './actionList';
import type { Language } from '@isomorphic/locatorGenerators';
import type { StackFrame } from '@protocol/channels';
import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import { AIConversation } from './aiConversation';
import { ToolbarButton } from '@web/components/toolbarButton';
import { useIsLLMAvailable, useLLMChat } from './llm';
import { useAsyncMemo } from '@web/uiUtils';
import { useSources } from './sourceTab';
const CommitInfoContext = React.createContext<MetadataWithCommitInfo | undefined>(undefined);
export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) {
return <CommitInfoContext.Provider value={commitInfo}>{children}</CommitInfoContext.Provider>;
}
export function useCommitInfo() {
return React.useContext(CommitInfoContext);
}
function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
return useAsyncMemo<string | undefined>(async () => {
for (const action of actions) {
for (const attachment of action.attachments ?? []) {
if (attachment.name === 'pageSnapshot') {
const response = await fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl }));
return await response.text();
}
}
}
}, [actions], undefined);
}
function useCodeFrame(stack: StackFrame[] | undefined, sources: Map<string, modelUtil.SourceModel>, width: number) {
const selectedFrame = stack?.[0];
const { source } = useSources(stack, 0, sources);
return React.useMemo(() => {
if (!source.content)
return '';
const targetLine = selectedFrame?.line ?? 0;
const lines = source.content.split('\n');
const start = Math.max(0, targetLine - width);
const end = Math.min(lines.length, targetLine + width);
const lineNumberWidth = String(end).length;
const codeFrame = lines.slice(start, end).map((line, i) => {
const lineNumber = start + i + 1;
const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, ' ');
if (lineNumber !== targetLine)
return ` ${(paddedLineNumber)} | ${line}`;
let highlightLine = `> ${paddedLineNumber} | ${line}`;
if (selectedFrame?.column)
highlightLine += `\n${' '.repeat(4 + lineNumberWidth + selectedFrame.column)}^`;
return highlightLine;
}).join('\n');
return codeFrame;
}, [source, selectedFrame, width]);
}
const CopyPromptButton: React.FC<{
error: string;
codeFrame: string;
pageSnapshot?: string;
diff?: string;
}> = ({ error, codeFrame, pageSnapshot, diff }) => {
const prompt = React.useMemo(
() => fixTestPrompt(
error + '\n\n' + codeFrame,
diff,
pageSnapshot
),
[error, diff, codeFrame, pageSnapshot]
);
if (!pageSnapshot)
return;
import { attachmentURL } from './attachmentsTab';
const CopyPromptButton: React.FC<{ prompt: string }> = ({ prompt }) => {
return (
<CopyToClipboardTextButton
value={prompt}
@ -110,30 +38,24 @@ const CopyPromptButton: React.FC<{
);
};
export type ErrorDescription = {
action?: modelUtil.ActionTraceEventInContext;
stack?: StackFrame[];
};
type ErrorsTabModel = {
errors: Map<string, ErrorDescription>;
errors: Map<string, modelUtil.ErrorDescription>;
};
export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): ErrorsTabModel {
return React.useMemo(() => {
if (!model)
return { errors: new Map() };
const errors = new Map<string, ErrorDescription>();
const errors = new Map<string, modelUtil.ErrorDescription>();
for (const error of model.errorDescriptors)
errors.set(error.message, error);
return { errors };
}, [model]);
}
function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource, sources }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void, sources: Map<string, modelUtil.SourceModel> }) {
function Error({ message, error, errorId, sdkLanguage, revealInSource }: { message: string, error: modelUtil.ErrorDescription, errorId: string, sdkLanguage: Language, revealInSource: (error: modelUtil.ErrorDescription) => void }) {
const [showLLM, setShowLLM] = React.useState(false);
const llmAvailable = useIsLLMAvailable();
const metadata = useCommitInfo();
let location: string | undefined;
let longLocation: string | undefined;
@ -144,7 +66,12 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
longLocation = stackFrame.file + ':' + stackFrame.line;
}
const codeFrame = useCodeFrame(error.stack, sources, 3);
const prompt = useAsyncMemo(async () => {
if (!error.prompt)
return;
const response = await fetch(attachmentURL(error.prompt));
return await response.text();
}, [error], undefined);
return <div style={{ display: 'flex', flexDirection: 'column', overflowX: 'clip' }}>
<div className='hbox' style={{
@ -160,9 +87,11 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
@ <span title={longLocation} onClick={() => revealInSource(error)}>{location}</span>
</div>}
<span style={{ position: 'absolute', right: '5px' }}>
{llmAvailable
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={metadata?.gitDiff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} codeFrame={codeFrame} pageSnapshot={pageSnapshot} diff={metadata?.gitDiff} />}
{prompt && (
llmAvailable
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} prompt={prompt} />
: <CopyPromptButton prompt={prompt} />
)}
</span>
</div>
@ -172,7 +101,7 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
</div>;
}
function FixWithAIButton({ conversationId, value, onChange, error, diff, pageSnapshot }: { conversationId: string, value: boolean, onChange: React.Dispatch<React.SetStateAction<boolean>>, error: string, diff?: string, pageSnapshot?: string }) {
function FixWithAIButton({ conversationId, value, onChange, prompt }: { conversationId: string, value: boolean, onChange: React.Dispatch<React.SetStateAction<boolean>>, prompt: string }) {
const chat = useLLMChat();
return <ToolbarButton
@ -184,20 +113,15 @@ function FixWithAIButton({ conversationId, value, onChange, error, diff, pageSna
`Don't include many headings in your output. Make sure what you're saying is correct, and take into account whether there might be a bug in the app.`
].join('\n'));
let content = `Here's the error: ${error}`;
let displayContent = `Help me with the error above.`;
let displayPrompt = `Help me with the error above.`;
const hasDiff = prompt.includes('Local changes:');
const hasSnapshot = prompt.includes('Page snapshot:');
if (hasDiff)
displayPrompt += ` Take the code diff${hasSnapshot ? ' and page snapshot' : ''} into account.`;
else if (hasSnapshot)
displayPrompt += ` Take the page snapshot into account.`;
if (diff)
content += `\n\nCode diff:\n${diff}`;
if (pageSnapshot)
content += `\n\nPage snapshot:\n${pageSnapshot}`;
if (diff)
displayContent += ` Take the code diff${pageSnapshot ? ' and page snapshot' : ''} into account.`;
else if (pageSnapshot)
displayContent += ` Take the page snapshot into account.`;
conversation.send(content, displayContent);
conversation.send(prompt, displayPrompt);
}
onChange(v => !v);
@ -212,21 +136,17 @@ function FixWithAIButton({ conversationId, value, onChange, error, diff, pageSna
export const ErrorsTab: React.FunctionComponent<{
errorsModel: ErrorsTabModel,
actions: modelUtil.ActionTraceEventInContext[],
wallTime: number,
sources: Map<string, modelUtil.SourceModel>,
sdkLanguage: Language,
revealInSource: (error: ErrorDescription) => void,
}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime, sources }) => {
const pageSnapshot = usePageSnapshot(actions);
revealInSource: (error: modelUtil.ErrorDescription) => void,
}> = ({ errorsModel, sdkLanguage, revealInSource, wallTime }) => {
if (!errorsModel.errors.size)
return <PlaceholderPanel text='No errors' />;
return <div className='fill' style={{ overflow: 'auto' }}>
{[...errorsModel.errors.entries()].map(([message, error]) => {
const errorId = `error-${wallTime}-${message}`;
return <Error key={errorId} errorId={errorId} message={message} error={error} sources={sources} revealInSource={revealInSource} sdkLanguage={sdkLanguage} pageSnapshot={pageSnapshot} />;
return <Error key={errorId} errorId={errorId} message={message} error={error} revealInSource={revealInSource} sdkLanguage={sdkLanguage} />;
})}
</div>;
};

View File

@ -49,10 +49,11 @@ export type ActionTreeItem = {
action?: ActionTraceEventInContext;
};
type ErrorDescription = {
export type ErrorDescription = {
action?: ActionTraceEventInContext;
stack?: StackFrame[];
message: string;
prompt?: trace.AfterActionTraceEventAttachment & { traceUrl: string };
};
export class MultiTraceModel {
@ -128,16 +129,19 @@ export class MultiTraceModel {
}
private _errorDescriptorsFromTestRunner(): ErrorDescription[] {
const errors: ErrorDescription[] = [];
for (const error of this.errors || []) {
if (!error.message)
continue;
errors.push({
stack: error.stack,
message: error.message
});
const errorPrompts: Record<string, trace.AfterActionTraceEventAttachment & { traceUrl: string }> = {};
for (const action of this.actions) {
for (const attachment of action.attachments ?? []) {
if (attachment.name.startsWith('_prompt-'))
errorPrompts[attachment.name] = { ...attachment, traceUrl: action.context.traceUrl };
}
}
return errors;
return this.errors.filter(e => !!e.message).map((error, i) => ({
stack: error.stack,
message: error.message,
prompt: errorPrompts[`_prompt-${i}`],
}));
}
}

View File

@ -27,7 +27,7 @@ import { CopyToClipboard } from './copyToClipboard';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
export function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sources: Map<string, SourceModel>, rootDir?: string, fallbackLocation?: SourceLocation) {
function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sources: Map<string, SourceModel>, rootDir?: string, fallbackLocation?: SourceLocation) {
return useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => {
const actionLocation = stack?.[selectedFrame];
const shouldUseFallback = !actionLocation?.file;

View File

@ -37,9 +37,7 @@ import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
import { CommitInfoProvider } from './errorsTab';
import { LLMProvider } from './llm';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -433,15 +431,13 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
<CommitInfoProvider commitInfo={testModel?.config.metadata as MetadataWithCommitInfo}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</CommitInfoProvider>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</div>
</div>}
sidebar={<div className='vbox ui-mode-sidebar'>

View File

@ -20,7 +20,6 @@ import { ActionList } from './actionList';
import { CallTab } from './callTab';
import { LogTab } from './logTab';
import { ErrorsTab, useErrorsTabModel } from './errorsTab';
import type { ErrorDescription } from './errorsTab';
import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil';
@ -58,7 +57,7 @@ export const Workbench: React.FunctionComponent<{
revealSource?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<modelUtil.ErrorDescription | undefined>(undefined);
const [revealedAttachment, setRevealedAttachment] = React.useState<[attachment: AfterActionTraceEventAttachment, renderCounter: number] | undefined>(undefined);
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
@ -193,13 +192,13 @@ export const Workbench: React.FunctionComponent<{
id: 'errors',
title: 'Errors',
errorCount: errorsModel.errors.size,
render: () => <ErrorsTab errorsModel={errorsModel} sources={sources} sdkLanguage={sdkLanguage} revealInSource={error => {
render: () => <ErrorsTab errorsModel={errorsModel} sdkLanguage={sdkLanguage} revealInSource={error => {
if (error.action)
setSelectedAction(error.action);
else
setRevealedError(error);
selectPropertiesTab('source');
}} actions={model?.actions ?? []} wallTime={model?.wallTime ?? 0} />
}} wallTime={model?.wallTime ?? 0} />
};
// Fallback location w/o action stands for file / test.

View File

@ -1,55 +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.
*/
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');
function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
export function fixTestPrompt(error: string, diff?: string, pageSnapshot?: string) {
const promptParts = [
`My Playwright test failed.`,
`Explain why, be concise, respect Playwright best practices.`,
'',
'Error:',
'',
'```js',
stripAnsiEscapes(error),
'```',
];
if (pageSnapshot) {
promptParts.push(
'',
'Page snapshot:',
'```yaml',
pageSnapshot,
'```',
);
}
if (diff) {
promptParts.push(
'',
'Local changes:',
'```diff',
diff,
'```',
);
}
return promptParts.join('\n');
}

View File

@ -420,40 +420,3 @@ test('should take screenshot when page is closed in afterEach', async ({ runInli
expect(result.failed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy();
});
test('should attach pageSnapshot with PLAYWRIGHT_COPY_PROMPT', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...testFiles,
}, { workers: 1 }, { PLAYWRIGHT_COPY_PROMPT: '1' });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(5);
expect(result.failed).toBe(5);
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.aria.yml',
'artifacts-own-context-failing',
' test-failed-1.aria.yml',
'artifacts-own-context-passing',
' test-finished-1.aria.yml',
'artifacts-passing',
' test-finished-1.aria.yml',
'artifacts-persistent-failing',
' test-failed-1.aria.yml',
'artifacts-persistent-passing',
' test-finished-1.aria.yml',
'artifacts-shared-shared-failing',
' test-failed-1.aria.yml',
' test-failed-2.aria.yml',
'artifacts-shared-shared-passing',
' test-finished-1.aria.yml',
' test-finished-2.aria.yml',
'artifacts-two-contexts',
' test-finished-1.aria.yml',
' test-finished-2.aria.yml',
'artifacts-two-contexts-failing',
' test-failed-1.aria.yml',
' test-failed-2.aria.yml',
]);
});

View File

@ -167,7 +167,7 @@ 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([expect.objectContaining({ name: '_prompt-0' })]);
});
test('should record trace', async ({ runInlineTest }) => {
@ -223,6 +223,7 @@ test('should record trace', async ({ runInlineTest }) => {
'After Hooks',
'fixture: page',
'fixture: context',
'attach "_prompt-0"',
'Worker Cleanup',
'fixture: browser',
]);

View File

@ -516,12 +516,13 @@ test('should work with video: on-first-retry', async ({ runInlineTest }) => {
const videoFailRetry = fs.readdirSync(dirRetry).find(file => file.endsWith('webm'));
expect(videoFailRetry).toBeTruthy();
expect(result.report.suites[0].specs[1].tests[0].results[0].attachments).toEqual([]);
const errorPrompt = expect.objectContaining({ name: '_prompt-0' });
expect(result.report.suites[0].specs[1].tests[0].results[0].attachments).toEqual([errorPrompt]);
expect(result.report.suites[0].specs[1].tests[0].results[1].attachments).toEqual([{
name: 'video',
contentType: 'video/webm',
path: path.join(dirRetry, videoFailRetry!),
}]);
}, errorPrompt]);
});
test('should work with video size', async ({ runInlineTest }) => {

View File

@ -129,6 +129,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
' fixture: context',
' fixture: request',
' apiRequestContext.dispose',
' attach "_prompt-0"',
'Worker Cleanup',
' fixture: browser',
]);
@ -327,6 +328,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve
'After Hooks',
' fixture: page',
' fixture: context',
' attach "_prompt-0"',
' afterAll hook',
' fixture: request',
' apiRequest.newContext',
@ -669,6 +671,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo
'After Hooks',
' fixture: page',
' fixture: context',
' attach "_prompt-0"',
'Worker Cleanup',
' fixture: browser',
]);
@ -980,6 +983,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest },
' page.setContent',
' fixture: page',
' fixture: context',
' attach "_prompt-0"',
' afterAll hook',
' fixture: barPage',
' barPage setup',
@ -1039,6 +1043,7 @@ test('should attribute worker fixture teardown to the right test', async ({ runI
expect(trace2.actionTree).toEqual([
'Before Hooks',
'After Hooks',
' attach "_prompt-0"',
'Worker Cleanup',
' fixture: foo',
' step in foo teardown',
@ -1148,6 +1153,7 @@ test('should not corrupt actions when no library trace is present', async ({ run
'After Hooks',
' fixture: foo',
' expect.toBe',
' attach "_prompt-0"',
'Worker Cleanup',
]);
});
@ -1178,6 +1184,7 @@ test('should record trace for manually created context in a failed test', async
'page.setContent',
'expect.toBe',
'After Hooks',
' attach "_prompt-0"',
'Worker Cleanup',
' fixture: browser',
]);
@ -1265,6 +1272,7 @@ test('should record trace after fixture teardown timeout', {
'page.evaluate',
'After Hooks',
' fixture: fixture',
' attach "_prompt-0"',
'Worker Cleanup',
' fixture: browser',
]);

View File

@ -518,11 +518,10 @@ test('fails', async ({ page }) => {
await page.locator('.tab-errors').getByRole('button', { name: 'Copy as Prompt' }).click();
const prompt = await page.evaluate(() => navigator.clipboard.readText());
expect(prompt, 'contains error').toContain('expect(received).toBe(expected)');
expect(prompt.replaceAll('\r\n', '\n'), 'contains codeframe').toContain(`
2 | test('fails', async ({ page }) => {
3 | await page.setContent('<button>Submit</button>');
> 4 | expect(1).toBe(2);
^
5 | });
expect(prompt.replaceAll('\r\n', '\n'), 'contains test sources').toContain(`
test('fails', async ({ page }) => {
await page.setContent('<button>Submit</button>');
expect(1).toBe(2);
});
`.trim());
});