diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index 8358018715774..afa2e7d63e6e6 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -355,6 +355,15 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte parent.children.push(item); item.parent = parent; } + + const inheritStack = (item: ActionTreeItem) => { + for (const child of item.children) { + child.action.stack = child.action.stack ?? item.action.stack; + inheritStack(child); + } + }; + inheritStack(rootItem); + return { rootItem, itemMap }; } diff --git a/packages/playwright/bundles/babel/package-lock.json b/packages/playwright/bundles/babel/package-lock.json index eeb420a495b60..461cfebb4dd0e 100644 --- a/packages/playwright/bundles/babel/package-lock.json +++ b/packages/playwright/bundles/babel/package-lock.json @@ -30,7 +30,8 @@ "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/preset-typescript": "^7.27.1" + "@babel/preset-typescript": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.12" }, "devDependencies": { "@types/babel__code-frame": "^7.0.6", diff --git a/packages/playwright/bundles/babel/package.json b/packages/playwright/bundles/babel/package.json index 379469c6e242d..1f906eac06d79 100644 --- a/packages/playwright/bundles/babel/package.json +++ b/packages/playwright/bundles/babel/package.json @@ -25,7 +25,8 @@ "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/preset-typescript": "^7.27.1" + "@babel/preset-typescript": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.12" }, "devDependencies": { "@types/babel__code-frame": "^7.0.6", diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index 27d94349b6a8a..9032cbb1e7e4d 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -22,10 +22,12 @@ import traverseFunction from '@babel/traverse'; import type { BabelFileResult, NodePath, PluginObj, TransformOptions } from '@babel/core'; import type { TemplateBuilder } from '@babel/template'; import type { ImportDeclaration, TSExportAssignment } from '@babel/types'; +import type { EncodedSourceMap } from '@jridgewell/gen-mapping'; export { codeFrameColumns } from '@babel/code-frame'; export { declare } from '@babel/helper-plugin-utils'; export { types } from '@babel/core'; +export * as genMapping from '@jridgewell/gen-mapping'; export const traverse = traverseFunction; function babelTransformOptions(isTypeScript: boolean, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): TransformOptions { @@ -120,7 +122,7 @@ function isTypeScript(filename: string) { return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts'); } -export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult | null { +export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][], inputSourceMap?: EncodedSourceMap): BabelFileResult | null { if (isTransforming) return null; @@ -128,6 +130,17 @@ export function babelTransform(code: string, filename: string, isModule: boolean isTransforming = true; try { const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue); + if (inputSourceMap) { + options.inputSourceMap = { + ...inputSourceMap, + sources: inputSourceMap.sources.map(s => s || ''), + names: [...inputSourceMap.names], + sourceRoot: inputSourceMap.sourceRoot, + sourcesContent: inputSourceMap.sourcesContent?.map(s => s || ''), + mappings: inputSourceMap.mappings, + file: inputSourceMap.file || '', + }; + } return babel.transform(code, { filename, ...options }); } finally { isTransforming = false; diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index ce61eecc90a66..9f37cdfcf1898 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -15,14 +15,17 @@ */ import type { BabelFileResult, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core'; +import type { EncodedSourceMap } from '../../bundles/babel/node_modules/@jridgewell/gen-mapping'; export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns; export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare; export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; export type BabelPlugin = [string, any?]; -export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult | null; +export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[], inputSourceMap?: EncodedSourceMap) => BabelFileResult | null; export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult; export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse; export type { NodePath, PluginObj, types as T } from '../../bundles/babel/node_modules/@types/babel__core'; export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils'; +export type { EncodedSourceMap } from '../../bundles/babel/node_modules/@jridgewell/gen-mapping'; +export const genMapping: typeof import('../../bundles/babel/node_modules/@jridgewell/gen-mapping') = require('./babelBundleImpl').genMapping; diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index d132f5f50dbfc..d1420ac699dc3 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -73,6 +73,8 @@ export function installSourceMapSupport() { environment: 'node', handleUncaughtExceptions: false, retrieveSourceMap(source) { + if (source.startsWith('file://') && !sourceMaps.has(source)) + source = source.substring('file://'.length); if (!sourceMaps.has(source)) return null; const sourceMapPath = sourceMaps.get(source)!; diff --git a/packages/playwright/src/transform/md.ts b/packages/playwright/src/transform/md.ts index 20d2732013b15..6b699e9f4ac3d 100644 --- a/packages/playwright/src/transform/md.ts +++ b/packages/playwright/src/transform/md.ts @@ -18,15 +18,21 @@ import fs from 'fs'; import path from 'path'; import { parseMarkdown } from '../utilsBundle'; +import { genMapping } from './babelBundle'; + import type * as mdast from 'mdast'; +import type { EncodedSourceMap } from './babelBundle'; -type Props = [string, string][]; +type Props = [string, Line][]; +// source is 1-based +type Line = { text: string; source?: { filename: string; line: number; column: number } }; +type Test = { title: Line, lines: Line[], props: Props }; -export function transformMDToTS(code: string, filename: string): string { +export function transformMDToTS(code: string, filename: string): { code: string, map: EncodedSourceMap } { const parsed = parseSpec(code, filename); const seed = parsed.props.find(prop => prop[0] === 'seed')?.[1]; if (seed) { - const seedFile = path.resolve(path.dirname(filename), seed); + const seedFile = path.resolve(path.dirname(filename), seed.text); const seedContents = fs.readFileSync(seedFile, 'utf-8'); const parsedSeed = parseSpec(seedContents, seedFile); if (parsedSeed.tests.length !== 1) @@ -40,36 +46,56 @@ export function transformMDToTS(code: string, filename: string): string { parsed.props.push(fixtures); } - const fixtures = parsed.props.find(prop => prop[0] === 'fixtures')?.[1] ?? '@playwright/test'; - const importLine = `import { test, expect } from ${escapeString(fixtures)};`; - const renderedTests = parsed.tests.map(test => { + const map = new genMapping.GenMapping({}); + const lines: string[] = []; + const addLine = (line: Line) => { + lines.push(line.text); + if (line.source) { + genMapping.addMapping(map, { + generated: { line: lines.length, column: 0 }, + source: line.source.filename, + original: { line: line.source.line, column: line.source.column - 1 }, + }); + } + }; + + const fixtures = parsed.props.find(prop => prop[0] === 'fixtures')?.[1] ?? { text: '@playwright/test' }; + addLine({ text: `import { test, expect } from ${escapeString(fixtures.text)};`, source: fixtures.source }); + addLine({ text: `test.describe(${escapeString(parsed.describe.text)}, () => {`, source: parsed.describe.source }); + for (const test of parsed.tests) { const tags: string[] = []; const annotations: { type: string, description: string }[] = []; for (const [key, value] of test.props) { if (key === 'tag') { - tags.push(...value.split(' ').map(s => s.trim()).filter(s => !!s)); + tags.push(...value.text.split(' ').map(s => s.trim()).filter(s => !!s)); } else if (key === 'annotation') { - if (!value.includes('=')) + if (!value.text.includes('=')) throw new Error(`while parsing ${filename}: annotation must be in format "type=description", found "${value}"`); - const [type, description] = value.split('=').map(s => s.trim()); + const [type, description] = value.text.split('=').map(s => s.trim()); annotations.push({ type, description }); } } let props = ''; if (tags.length || annotations.length) { - props = '{\n'; + props = '{ '; if (tags.length) - props += ` tag: [${tags.map(tag => escapeString(tag)).join(', ')}],\n`; + props += `tag: [${tags.map(tag => escapeString(tag)).join(', ')}], `; if (annotations.length) - props += ` annotation: [${annotations.map(a => `{ type: ${escapeString(a.type)}, description: ${escapeString(a.description)} }`).join(', ')}],\n`; - props += ' }, '; + props += `annotation: [${annotations.map(a => `{ type: ${escapeString(a.type)}, description: ${escapeString(a.description)} }`).join(', ')}], `; + props += '}, '; } - return `\n test(${escapeString(test.title)}, ${props}async ({ page, agent }) => {\n` + - test.lines.map(line => ' ' + line).join('\n') + `\n });\n`; - }); + // TODO: proper source mapping for props + addLine({ text: ` test(${escapeString(test.title.text)}, ${props}async ({ page, agent }) => {`, source: test.title.source }); + for (const line of test.lines) + addLine({ text: ' ' + line.text, source: line.source }); + addLine({ text: ` });`, source: test.title.source }); + } + addLine({ text: `});`, source: parsed.describe.source }); + addLine({ text: `` }); - const result = `${importLine}\ntest.describe(${escapeString(parsed.describe)}, () => {${renderedTests.join('')}\n});\n`; - return result; + const encodedMap = genMapping.toEncodedMap(map); + const result = lines.join('\n'); + return { code: result, map: encodedMap }; } function escapeString(s: string): string { @@ -81,16 +107,16 @@ function parsingError(filename: string, node: mdast.Node | undefined, message: s return new Error(`while parsing ${filename}${position}: ${message}`); } -function asText(filename: string, node: mdast.Parent, errorMessage: string, skipChild?: mdast.Node): string { +function asText(filename: string, node: mdast.Parent, errorMessage: string, skipChild?: mdast.Node): Line { let children = node.children.filter(child => child !== skipChild); while (children.length === 1 && children[0].type === 'paragraph') children = children[0].children; if (children.length !== 1 || children[0].type !== 'text') throw parsingError(filename, node, errorMessage); - return children[0].value; + return { text: children[0].value, source: node.position ? { filename, line: node.position.start.line, column: node.position.start.column } : undefined }; } -function parseSpec(content: string, filename: string): { describe: string, tests: { title: string, lines: string[], props: Props }[], props: Props } { +function parseSpec(content: string, filename: string): { describe: Line, tests: Test[], props: Props } { const root = parseMarkdown(content); const props: Props = []; @@ -106,7 +132,7 @@ function parseSpec(content: string, filename: string): { describe: string, tests children.shift(); } - const tests: { title: string, lines: string[], props: Props }[] = []; + const tests: Test[] = []; while (children.length) { let nextIndex = children.findIndex((n, i) => i > 0 && n.type === 'heading' && n.depth === 3); if (nextIndex === -1) @@ -120,10 +146,10 @@ function parseSpec(content: string, filename: string): { describe: string, tests function parseProp(filename: string, node: mdast.ListItem, props: Props) { const propText = asText(filename, node, `property must be a list item without children`); - const match = propText.match(/^([^:]+):(.*)$/); + const match = propText.text.match(/^([^:]+):(.*)$/); if (!match) throw parsingError(filename, node, `property must be in format "key: value"`); - props.push([match[1].trim(), match[2].trim()]); + props.push([match[1].trim(), { text: match[2].trim(), source: propText.source }]); } function parseProps(filename: string, node: mdast.List, props: Props) { @@ -134,7 +160,7 @@ function parseProps(filename: string, node: mdast.List, props: Props) { } } -function parseTest(filename: string, nodes: mdast.Node[]): { title: string, lines: string[], props: Props } { +function parseTest(filename: string, nodes: mdast.Node[]): Test { const titleNode = nodes[0] as mdast.Heading; nodes.shift(); if (titleNode.type !== 'heading' || titleNode.depth !== 3) @@ -144,7 +170,7 @@ function parseTest(filename: string, nodes: mdast.Node[]): { title: string, line const props: Props = []; let handlingProps = true; - const lines: string[] = []; + const lines: Line[] = []; const visit = (node: mdast.Node, indent: string) => { if (node.type === 'list') { for (const child of (node as mdast.List).children) @@ -156,30 +182,31 @@ function parseTest(filename: string, nodes: mdast.Node[]): { title: string, line const lastChild = listItem.children[listItem.children.length - 1]; if (lastChild?.type === 'code') { handlingProps = false; - const text = asText(filename, listItem, `code step must be a list item with a single code block`, lastChild); - lines.push(`${indent}await test.step(${escapeString(text)}, async () => {`); - lines.push(lastChild.value.split('\n').map(line => indent + ' ' + line).join('\n')); - lines.push(`${indent}});`); + const { text, source } = asText(filename, listItem, `code step must be a list item with a single code block`, lastChild); + lines.push({ text: `${indent}await test.step(${escapeString(text)}, async () => {`, source }); + for (const [index, code] of lastChild.value.split('\n').entries()) + lines.push({ text: indent + ' ' + code, source: lastChild.position ? { filename: filename, line: lastChild.position.start.line + 1 + index, column: lastChild.position.start.column } : undefined }); + lines.push({ text: `${indent}});`, source }); } else { - const text = asText(filename, listItem, `step must contain a single instruction`, lastChild?.type === 'list' ? lastChild : undefined); + const { text, source } = asText(filename, listItem, `step must contain a single instruction`, lastChild?.type === 'list' ? lastChild : undefined); let isGroup = false; if (handlingProps && lastChild?.type !== 'list' && ['tag:', 'annotation:'].some(prefix => text.startsWith(prefix))) { parseProp(filename, listItem, props); } else if (text.startsWith('group:')) { isGroup = true; - lines.push(`${indent}await test.step(${escapeString(text.substring('group:'.length).trim())}, async () => {`); + lines.push({ text: `${indent}await test.step(${escapeString(text.substring('group:'.length).trim())}, async () => {`, source }); } else if (text.startsWith('expect:')) { handlingProps = false; const assertion = text.substring('expect:'.length).trim(); - lines.push(`${indent}await agent.expect(${escapeString(assertion)});`); + lines.push({ text: `${indent}await agent.expect(${escapeString(assertion)});`, source }); } else if (!text.startsWith('//')) { handlingProps = false; - lines.push(`${indent}await agent.perform(${escapeString(text)});`); + lines.push({ text: `${indent}await agent.perform(${escapeString(text)});`, source }); } if (lastChild?.type === 'list') visit(lastChild, indent + (isGroup ? ' ' : '')); if (isGroup) - lines.push(`${indent}});`); + lines.push({ text: `${indent}});`, source }); } } else { throw parsingError(filename, node, `test step must be a markdown list item`); diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index a177d16d5fd04..862023cc5f9b8 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -28,7 +28,7 @@ import { belongsToNodeModules, currentFileDepsCollector, getFromCompilationCache import { addHook } from '../third_party/pirates'; import { transformMDToTS } from './md'; -import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; +import type { BabelPlugin, BabelTransformFunction, EncodedSourceMap } from './babelBundle'; import type { Location } from '../../types/testReporter'; import type { LoadedTsConfig } from '../third_party/tsconfig-loader'; import type { Matcher } from '../util'; @@ -221,6 +221,15 @@ export function setTransformData(pluginName: string, value: any) { } export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } { + // TODO: ideally, we would not transform before checking the cache. However, the source + // currently depends on the seed.md, so "originalCode" is not enough to produce a cache key. + let inputSourceMap: EncodedSourceMap | undefined; + if (filename.endsWith('.md')) { + const transformed = transformMDToTS(originalCode, filename); + originalCode = transformed.code; + inputSourceMap = transformed.map; + } + const hasPreprocessor = process.env.PW_TEST_SOURCE_TRANSFORM && process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE && @@ -232,16 +241,13 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? if (cachedCode !== undefined) return { code: cachedCode, serializedCache }; - if (filename.endsWith('.md')) - originalCode = transformMDToTS(originalCode, filename); - // We don't use any browserslist data, but babel checks it anyway. // Silence the annoying warning. process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'; const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); transformData = new Map(); - const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); + const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue, inputSourceMap); if (!babelResult?.code) return { code: originalCode, serializedCache }; const { code, map } = babelResult;