Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/playwright/bundles/babel/src/babelBundleImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import type { ImportDeclaration, TSExportAssignment } from '@babel/types';
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 {
function babelTransformOptions(isTypeScript: boolean, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][], inputSourceMap?: any): TransformOptions {
const plugins = [
[require('@babel/plugin-syntax-import-attributes'), { deprecatedAssertSyntax: true }],
];
Expand Down Expand Up @@ -111,6 +112,7 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins
],
compact: false,
sourceMaps: 'both',
inputSourceMap,
};
}

Expand All @@ -120,14 +122,14 @@ 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?: any): BabelFileResult | null {
if (isTransforming)
return null;

// Prevent reentry while requiring plugins lazily.
isTransforming = true;
try {
const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue, inputSourceMap);
return babel.transform(code, { filename, ...options });
} finally {
isTransforming = false;
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright/src/transform/babelBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export const declare: typeof import('../../bundles/babel/node_modules/@types/bab
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?: any) => 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 const genMapping: typeof import('../../bundles/babel/node_modules/@jridgewell/gen-mapping') = require('./babelBundleImpl').genMapping;
136 changes: 96 additions & 40 deletions packages/playwright/src/transform/md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import fs from 'fs';
import path from 'path';

import { parseMarkdown } from '../utilsBundle';
import { genMapping } from './babelBundle';
import type * as mdast from 'mdast';

type Props = [string, string][];
type Props = { key: string; value: string; source?: SourceLocation }[];
type SourceLocation = { filename: string; line: number; column: number };
type Line = { text: string; source?: SourceLocation };

export function transformMDToTS(code: string, filename: string): string {
export function transformMDToTS(code: string, filename: string): { code: string, map: ReturnType<typeof genMapping.toEncodedMap> } {
const parsed = parseSpec(code, filename);
const seed = parsed.props.find(prop => prop[0] === 'seed')?.[1];
const seed = parsed.props.find(prop => prop.key === 'seed')?.value;
if (seed) {
const seedFile = path.resolve(path.dirname(filename), seed);
const seedContents = fs.readFileSync(seedFile, 'utf-8');
Expand All @@ -35,41 +38,72 @@ export function transformMDToTS(code: string, filename: string): string {
throw new Error(`while parsing ${seedFile}: seed test must not have properties`);
for (const test of parsed.tests)
test.lines = parsedSeed.tests[0].lines.concat(test.lines);
const fixtures = parsedSeed.props.find(prop => prop[0] === 'fixtures');
if (fixtures && !parsed.props.find(prop => prop[0] === 'fixtures'))
const fixtures = parsedSeed.props.find(prop => prop.key === 'fixtures');
if (fixtures && !parsed.props.find(prop => prop.key === 'fixtures'))
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 tags: string[] = [];
const annotations: { type: string, description: string }[] = [];
for (const [key, value] of test.props) {
const fixtures = parsed.props.find(prop => prop.key === 'fixtures')?.value ?? '@playwright/test';

const map = new genMapping.GenMapping({ file: filename });
const outputLines: string[] = [];

const addLine = (line: Line) => {
outputLines.push(line.text);
if (line.source) {
genMapping.addMapping(map, {
generated: { line: outputLines.length, column: 0 },
source: line.source.filename,
original: { line: line.source.line, column: line.source.column },
});
}
};

addLine({ text: `import { test, expect } from ${escapeString(fixtures)};` });
addLine({ text: `test.describe(${escapeString(parsed.describe.text)}, () => {`, source: parsed.describe.source });

for (const test of parsed.tests) {
const tags: { tag: string; source?: SourceLocation }[] = [];
const annotations: { type: string; description: string; source?: SourceLocation }[] = [];
for (const { key, value, source } of test.props) {
if (key === 'tag') {
tags.push(...value.split(' ').map(s => s.trim()).filter(s => !!s));
tags.push(...value.split(' ').map(s => s.trim()).filter(s => !!s).map(tag => ({ tag, source })));
} else if (key === 'annotation') {
if (!value.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());
annotations.push({ type, description });
annotations.push({ type, description, source });
}
}
let props = '';

if (tags.length || annotations.length) {
props = '{\n';
if (tags.length)
props += ` tag: [${tags.map(tag => escapeString(tag)).join(', ')}],\n`;
if (annotations.length)
props += ` annotation: [${annotations.map(a => `{ type: ${escapeString(a.type)}, description: ${escapeString(a.description)} }`).join(', ')}],\n`;
props += ' }, ';
addLine({ text: ` test(${escapeString(test.title.text)}, {`, source: test.title.source });
if (tags.length) {
addLine({ text: ` tag: [` });
for (const t of tags)
addLine({ text: ` ${escapeString(t.tag)},`, source: t.source });
addLine({ text: ` ],` });
}
if (annotations.length) {
addLine({ text: ` annotation: [` });
for (const a of annotations)
addLine({ text: ` { type: ${escapeString(a.type)}, description: ${escapeString(a.description)} },`, source: a.source });
addLine({ text: ` ],` });
}
addLine({ text: ` }, async ({ page, agent }) => {` });
} else {
addLine({ text: ` test(${escapeString(test.title.text)}, async ({ page, agent }) => {`, source: test.title.source });
}
return `\n test(${escapeString(test.title)}, ${props}async ({ page, agent }) => {\n` +
test.lines.map(line => ' ' + line).join('\n') + `\n });\n`;
});

const result = `${importLine}\ntest.describe(${escapeString(parsed.describe)}, () => {${renderedTests.join('')}\n});\n`;
return result;
for (const line of test.lines)
addLine({ text: ' ' + line.text, source: line.source });
addLine({ text: ` });` });
}

addLine({ text: `});` });

const encodedMap = genMapping.toEncodedMap(map);
return { code: outputLines.join('\n') + '\n', map: encodedMap };
}

function escapeString(s: string): string {
Expand All @@ -90,7 +124,13 @@ function asText(filename: string, node: mdast.Parent, errorMessage: string, skip
return children[0].value;
}

function parseSpec(content: string, filename: string): { describe: string, tests: { title: string, lines: string[], props: Props }[], props: Props } {
function getSource(filename: string, node: mdast.Node): SourceLocation | undefined {
if (!node.position)
return undefined;
return { filename, line: node.position.start.line, column: node.position.start.column };
}

function parseSpec(content: string, filename: string): { describe: Line, tests: { title: Line, lines: Line[], props: Props }[], props: Props } {
const root = parseMarkdown(content);
const props: Props = [];

Expand All @@ -99,20 +139,24 @@ function parseSpec(content: string, filename: string): { describe: string, tests
children.shift();
if (describeNode?.type !== 'heading' || describeNode.depth !== 2)
throw parsingError(filename, describeNode, `describe title must be ##`);
const describe = asText(filename, describeNode, `describe title must be ##`);
const describe: Line = {
text: asText(filename, describeNode, `describe title must be ##`),
source: getSource(filename, describeNode),
};

if (children[0]?.type === 'list') {
parseProps(filename, children[0], props);
children.shift();
}

const tests: { title: string, lines: string[], props: Props }[] = [];
const tests: { title: Line, lines: Line[], props: Props }[] = [];
while (children.length) {
let nextIndex = children.findIndex((n, i) => i > 0 && n.type === 'heading' && n.depth === 3);
if (nextIndex === -1)
nextIndex = children.length;
const testNodes = children.splice(0, nextIndex);
tests.push(parseTest(filename, testNodes));
const test = parseTest(filename, testNodes);
tests.push(test);
}

return { describe, tests, props };
Expand All @@ -123,7 +167,7 @@ function parseProp(filename: string, node: mdast.ListItem, props: Props) {
const match = propText.match(/^([^:]+):(.*)$/);
if (!match)
throw parsingError(filename, node, `property must be in format "key: value"`);
props.push([match[1].trim(), match[2].trim()]);
props.push({ key: match[1].trim(), value: match[2].trim(), source: getSource(filename, node) });
}

function parseProps(filename: string, node: mdast.List, props: Props) {
Expand All @@ -134,17 +178,20 @@ 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[]): { title: Line, lines: Line[], props: Props } {
const titleNode = nodes[0] as mdast.Heading;
nodes.shift();
if (titleNode.type !== 'heading' || titleNode.depth !== 3)
throw parsingError(filename, titleNode, `test title must be ###`);
const title = asText(filename, titleNode, `test title must be ###`);
const title: Line = {
text: asText(filename, titleNode, `test title must be ###`),
source: getSource(filename, titleNode),
};

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)
Expand All @@ -154,32 +201,41 @@ function parseTest(filename: string, nodes: mdast.Node[]): { title: string, line
if (node.type === 'listItem') {
const listItem = node as mdast.ListItem;
const lastChild = listItem.children[listItem.children.length - 1];
const source = getSource(filename, listItem);

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}});`);
lines.push({ text: `${indent}await test.step(${escapeString(text)}, async () => {`, source });
for (const [i, codeLine] of lastChild.value.split('\n').entries()) {
const codeSource = lastChild.position ? {
filename,
line: lastChild.position.start.line + 1 + i,
column: lastChild.position.start.column,
} : undefined;
lines.push({ text: indent + ' ' + codeLine, source: codeSource });
}
lines.push({ text: `${indent}});`, source });
} else {
const text = 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`);
Expand Down
10 changes: 7 additions & 3 deletions packages/playwright/src/transform/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,20 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
if (cachedCode !== undefined)
return { code: cachedCode, serializedCache };

if (filename.endsWith('.md'))
originalCode = transformMDToTS(originalCode, filename);
let inputSourceMap: any;
if (filename.endsWith('.md')) {
const mdResult = transformMDToTS(originalCode, filename);
originalCode = mdResult.code;
inputSourceMap = mdResult.map;
}

// 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<string, any>();
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;
Expand Down
Loading
Loading