cherry-pick(#35032): chore: improve prompt to use code frame and inline error
This commit is contained in:
parent
230da2927d
commit
7cccd68de6
|
@ -12,6 +12,8 @@ export default defineConfig({
|
||||||
/* Maximum time one test can run for. */
|
/* Maximum time one test can run for. */
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
|
|
||||||
|
captureGitInfo: { commit: true, diff: true },
|
||||||
|
|
||||||
expect: {
|
expect: {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -134,6 +134,34 @@ export function splitErrorMessage(message: string): { name: string, message: str
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseErrorStack(stack: string, pathSeparator: string, showInternalStackFrames: boolean = false): {
|
||||||
|
message: string;
|
||||||
|
stackLines: string[];
|
||||||
|
location?: StackFrame;
|
||||||
|
} {
|
||||||
|
const lines = stack.split('\n');
|
||||||
|
let firstStackLine = lines.findIndex(line => line.startsWith(' at '));
|
||||||
|
if (firstStackLine === -1)
|
||||||
|
firstStackLine = lines.length;
|
||||||
|
const message = lines.slice(0, firstStackLine).join('\n');
|
||||||
|
const stackLines = lines.slice(firstStackLine);
|
||||||
|
let location: StackFrame | undefined;
|
||||||
|
for (const line of stackLines) {
|
||||||
|
const frame = parseStackFrame(line, pathSeparator, showInternalStackFrames);
|
||||||
|
if (!frame || !frame.file)
|
||||||
|
continue;
|
||||||
|
if (belongsToNodeModules(frame.file, pathSeparator))
|
||||||
|
continue;
|
||||||
|
location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { message, stackLines, location };
|
||||||
|
}
|
||||||
|
|
||||||
|
function belongsToNodeModules(file: string, pathSeparator: string) {
|
||||||
|
return file.includes(`${pathSeparator}node_modules${pathSeparator}`);
|
||||||
|
}
|
||||||
|
|
||||||
const re = new RegExp('^' +
|
const re = new RegExp('^' +
|
||||||
// Sometimes we strip out the ' at' because it's noisy
|
// Sometimes we strip out the ' at' because it's noisy
|
||||||
'(?:\\s*at )?' +
|
'(?:\\s*at )?' +
|
||||||
|
|
|
@ -8,7 +8,11 @@ common/
|
||||||
|
|
||||||
[index.ts]
|
[index.ts]
|
||||||
@testIsomorphic/**
|
@testIsomorphic/**
|
||||||
|
./prompt.ts
|
||||||
./worker/testTracing.ts
|
./worker/testTracing.ts
|
||||||
|
|
||||||
[internalsForTest.ts]
|
[internalsForTest.ts]
|
||||||
**
|
**
|
||||||
|
|
||||||
|
[prompt.ts]
|
||||||
|
./transform/babelBundle.ts
|
||||||
|
|
|
@ -22,15 +22,15 @@ import { setBoxedStackPrefixes, asLocator, createGuid, currentZone, debugMode, i
|
||||||
|
|
||||||
import { currentTestInfo } from './common/globals';
|
import { currentTestInfo } from './common/globals';
|
||||||
import { rootTestType } from './common/testType';
|
import { rootTestType } from './common/testType';
|
||||||
import { stripAnsiEscapes } from './util';
|
import { attachErrorPrompts } from './prompt';
|
||||||
|
|
||||||
import type { MetadataWithCommitInfo } from './isomorphic/types';
|
|
||||||
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
|
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
|
||||||
import type { ContextReuseMode } from './common/config';
|
import type { ContextReuseMode } from './common/config';
|
||||||
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
|
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
|
||||||
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
|
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
|
||||||
import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright';
|
import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright';
|
||||||
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
||||||
|
|
||||||
export { expect } from './matchers/expect';
|
export { expect } from './matchers/expect';
|
||||||
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
||||||
|
|
||||||
|
@ -619,6 +619,7 @@ class ArtifactsRecorder {
|
||||||
|
|
||||||
private _screenshotRecorder: SnapshotRecorder;
|
private _screenshotRecorder: SnapshotRecorder;
|
||||||
private _pageSnapshot: string | undefined;
|
private _pageSnapshot: string | undefined;
|
||||||
|
private _sourceCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption) {
|
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption) {
|
||||||
this._playwright = playwright;
|
this._playwright = playwright;
|
||||||
|
@ -701,71 +702,7 @@ class ArtifactsRecorder {
|
||||||
})));
|
})));
|
||||||
|
|
||||||
await this._screenshotRecorder.persistTemporary();
|
await this._screenshotRecorder.persistTemporary();
|
||||||
await this._attachErrorPrompts();
|
await attachErrorPrompts(this._testInfo, this._sourceCache, this._pageSnapshot);
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
private async _startTraceChunkOnContextCreation(tracing: Tracing) {
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* 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 fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { parseErrorStack } from 'playwright-core/lib/utils';
|
||||||
|
|
||||||
|
import { stripAnsiEscapes } from './util';
|
||||||
|
import { codeFrameColumns } from './transform/babelBundle';
|
||||||
|
|
||||||
|
import type { TestInfo } from '../types/test';
|
||||||
|
import type { MetadataWithCommitInfo } from './isomorphic/types';
|
||||||
|
import type { TestInfoImpl } from './worker/testInfo';
|
||||||
|
|
||||||
|
export async function attachErrorPrompts(testInfo: TestInfo, sourceCache: Map<string, string>, ariaSnapshot: string | undefined) {
|
||||||
|
if (process.env.PLAYWRIGHT_NO_COPY_PROMPT)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const [index, error] of testInfo.errors.entries()) {
|
||||||
|
if (testInfo.attachments.find(a => a.name === `_prompt-${index}`))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const metadata = testInfo.config.metadata as MetadataWithCommitInfo;
|
||||||
|
|
||||||
|
const promptParts = [
|
||||||
|
`# Instructions`,
|
||||||
|
'',
|
||||||
|
`- Following Playwright test failed.`,
|
||||||
|
`- Explain why, be concise, respect Playwright best practices.`,
|
||||||
|
`- Provide a snippet of code with the fix is possible.`,
|
||||||
|
'',
|
||||||
|
`# Test info`,
|
||||||
|
'',
|
||||||
|
`- Name: ${testInfo.titlePath.slice(1).join(' >> ')}`,
|
||||||
|
`- Location: ${testInfo.file}:${testInfo.line}:${testInfo.column}`,
|
||||||
|
'',
|
||||||
|
'# Error details',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
stripAnsiEscapes(error.stack || error.message || ''),
|
||||||
|
'```',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ariaSnapshot) {
|
||||||
|
promptParts.push(
|
||||||
|
'',
|
||||||
|
'# Page snapshot',
|
||||||
|
'',
|
||||||
|
'```yaml',
|
||||||
|
ariaSnapshot,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedError = error.stack ? parseErrorStack(error.stack, path.sep) : undefined;
|
||||||
|
const inlineMessage = stripAnsiEscapes(parsedError?.message || error.message || '').split('\n')[0];
|
||||||
|
const location = parsedError?.location || { file: testInfo.file, line: testInfo.line, column: testInfo.column };
|
||||||
|
const source = await loadSource(location.file, sourceCache);
|
||||||
|
const codeFrame = codeFrameColumns(
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
line: location.line,
|
||||||
|
column: location.column
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
highlightCode: false,
|
||||||
|
linesAbove: 100,
|
||||||
|
linesBelow: 100,
|
||||||
|
message: inlineMessage || undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
promptParts.push(
|
||||||
|
'',
|
||||||
|
'# Test source',
|
||||||
|
'',
|
||||||
|
'```ts',
|
||||||
|
codeFrame,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (metadata.gitDiff) {
|
||||||
|
promptParts.push(
|
||||||
|
'',
|
||||||
|
'# Local changes',
|
||||||
|
'',
|
||||||
|
'```diff',
|
||||||
|
metadata.gitDiff,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(testInfo as TestInfoImpl)._attach({
|
||||||
|
name: `_prompt-${index}`,
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
body: Buffer.from(promptParts.join('\n')),
|
||||||
|
}, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSource(file: string, sourceCache: Map<string, string>) {
|
||||||
|
let source = sourceCache.get(file);
|
||||||
|
if (!source) {
|
||||||
|
// A mild race is Ok here.
|
||||||
|
source = await fs.promises.readFile(file, 'utf8');
|
||||||
|
sourceCache.set(file, source);
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
|
@ -16,8 +16,7 @@
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
|
import { getPackageManagerExecCommand, parseErrorStack } from 'playwright-core/lib/utils';
|
||||||
import { parseStackFrame } from 'playwright-core/lib/utils';
|
|
||||||
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
|
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
|
||||||
import { colors as realColors, noColors } from 'playwright-core/lib/utils';
|
import { colors as realColors, noColors } from 'playwright-core/lib/utils';
|
||||||
|
|
||||||
|
@ -540,23 +539,7 @@ export function prepareErrorStack(stack: string): {
|
||||||
stackLines: string[];
|
stackLines: string[];
|
||||||
location?: Location;
|
location?: Location;
|
||||||
} {
|
} {
|
||||||
const lines = stack.split('\n');
|
return parseErrorStack(stack, path.sep, !!process.env.PWDEBUGIMPL);
|
||||||
let firstStackLine = lines.findIndex(line => line.startsWith(' at '));
|
|
||||||
if (firstStackLine === -1)
|
|
||||||
firstStackLine = lines.length;
|
|
||||||
const message = lines.slice(0, firstStackLine).join('\n');
|
|
||||||
const stackLines = lines.slice(firstStackLine);
|
|
||||||
let location: Location | undefined;
|
|
||||||
for (const line of stackLines) {
|
|
||||||
const frame = parseStackFrame(line, path.sep, !!process.env.PWDEBUGIMPL);
|
|
||||||
if (!frame || !frame.file)
|
|
||||||
continue;
|
|
||||||
if (belongsToNodeModules(frame.file))
|
|
||||||
continue;
|
|
||||||
location = { file: frame.file, column: frame.column || 0, line: frame.line || 0 };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return { message, stackLines, location };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function characterWidth(c: string) {
|
function characterWidth(c: string) {
|
||||||
|
@ -611,10 +594,6 @@ export function fitToWidth(line: string, width: number, prefix?: string): string
|
||||||
return taken.reverse().join('');
|
return taken.reverse().join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function belongsToNodeModules(file: string) {
|
|
||||||
return file.includes(`${path.sep}node_modules${path.sep}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFromEnv(name: string): string | undefined {
|
function resolveFromEnv(name: string): string | undefined {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value)
|
if (value)
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { test, expect, retries } from './ui-mode-fixtures';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel', retries });
|
test.describe.configure({ mode: 'parallel', retries });
|
||||||
|
|
||||||
test('openai', async ({ runUITest, server }) => {
|
test.fixme('openai', async ({ runUITest, server }) => {
|
||||||
server.setRoute('/v1/chat/completions', async (req, res) => {
|
server.setRoute('/v1/chat/completions', async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||||
|
@ -59,7 +59,7 @@ test('openai', async ({ runUITest, server }) => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('anthropic', async ({ runUITest, server }) => {
|
test.fixme('anthropic', async ({ runUITest, server }) => {
|
||||||
server.setRoute('/v1/messages', async (req, res) => {
|
server.setRoute('/v1/messages', async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||||
|
|
|
@ -519,9 +519,11 @@ test('fails', async ({ page }) => {
|
||||||
const prompt = await page.evaluate(() => navigator.clipboard.readText());
|
const prompt = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
expect(prompt, 'contains error').toContain('expect(received).toBe(expected)');
|
expect(prompt, 'contains error').toContain('expect(received).toBe(expected)');
|
||||||
expect(prompt.replaceAll('\r\n', '\n'), 'contains test sources').toContain(`
|
expect(prompt.replaceAll('\r\n', '\n'), 'contains test sources').toContain(`
|
||||||
test('fails', async ({ page }) => {
|
1 | import { test, expect } from '@playwright/test';
|
||||||
await page.setContent('<button>Submit</button>');
|
2 | test('fails', async ({ page }) => {
|
||||||
expect(1).toBe(2);
|
3 | await page.setContent('<button>Submit</button>');
|
||||||
});
|
> 4 | expect(1).toBe(2);
|
||||||
|
| ^ Error: expect(received).toBe(expected) // Object.is equality
|
||||||
|
5 | });
|
||||||
`.trim());
|
`.trim());
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue