refactor: store copy prompt contents in attachment (#34995)
This commit is contained in:
parent
5f75e69279
commit
88623ae3c2
|
@ -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);
|
||||
}
|
|
@ -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<{
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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 */ }),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)!);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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, '');
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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}`],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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');
|
||||
}
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue