From bdcd15ecfc77f0526de13d2533450edce58d611e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 12 Oct 2026 15:36:56 -0700 Subject: [PATCH 1/2] feat(test-runner): experimental esbuild transpilation --- package-lock.json | 36 +-- packages/playwright/package.json | 8 + .../src/transform/esbuildTransform.ts | 92 ++++++++ .../playwright/src/transform/transform.ts | 14 +- tests/library/trace-viewer.spec.ts | 2 +- tests/page/page-evaluate-callback.spec.ts | 2 +- tests/page/page-evaluate.spec.ts | 8 +- tests/playwright-test/babel.spec.ts | 5 + tests/playwright-test/esbuild.spec.ts | 210 ++++++++++++++++++ tests/playwright-test/list-mode.spec.ts | 2 +- tests/playwright-test/loader.spec.ts | 10 +- tests/playwright-test/test-server.spec.ts | 2 +- .../ui-mode-test-output.spec.ts | 14 +- .../ui-mode-test-source.spec.ts | 2 +- utils/build/build.js | 2 + 15 files changed, 359 insertions(+), 50 deletions(-) create mode 100644 packages/playwright/src/transform/esbuildTransform.ts create mode 100644 tests/playwright-test/esbuild.spec.ts diff --git a/package-lock.json b/package-lock.json index ded8822a7cfd8..5465dbfc0476c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1228,7 +1228,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1245,7 +1244,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1262,7 +1260,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1279,7 +1276,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1296,7 +1292,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1313,7 +1308,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1330,7 +1324,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1347,7 +1340,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1364,7 +1356,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1381,7 +1372,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1398,7 +1388,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1415,7 +1404,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1432,7 +1420,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1449,7 +1436,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1466,7 +1452,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1483,7 +1468,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1500,7 +1484,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1517,7 +1500,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1534,7 +1516,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1551,7 +1532,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1568,7 +1548,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,7 +1564,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1602,7 +1580,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1619,7 +1596,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1636,7 +1612,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1653,7 +1628,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4939,7 +4913,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -9762,6 +9736,14 @@ }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "esbuild": ">=0.21.5" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + } } }, "packages/playwright-browser-chromium": { diff --git a/packages/playwright/package.json b/packages/playwright/package.json index e2ae11459fcfc..7779613bcb4a4 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -46,5 +46,13 @@ "license": "Apache-2.0", "dependencies": { "playwright-core": "1.65.0-next" + }, + "peerDependencies": { + "esbuild": ">=0.21.5" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + } } } diff --git a/packages/playwright/src/transform/esbuildTransform.ts b/packages/playwright/src/transform/esbuildTransform.ts new file mode 100644 index 0000000000000..0e26d0e000d10 --- /dev/null +++ b/packages/playwright/src/transform/esbuildTransform.ts @@ -0,0 +1,92 @@ +/** + * 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 path from 'path'; + +import { libPath } from '../package'; + +import type * as esbuild from 'esbuild'; +import type { JsxOptions } from './babelBundle'; + +export type Esbuild = typeof esbuild; + +export function loadEsbuild(): Esbuild { + try { + return require('esbuild'); + } catch (error) { + throw new Error(`PLAYWRIGHT_EXPERIMENTAL_ESBUILD requires the "esbuild" package, install it with "npm install --save-dev esbuild"`, { cause: error }); + } +} + +export function esbuildTransform(esbuild: Esbuild, code: string, filename: string, isModule: boolean, useDefineForClassFields: boolean, jsx: JsxOptions | undefined): { code: string, map: any } { + const options = transformOptions(filename, isModule, useDefineForClassFields, jsx); + let result: esbuild.TransformResult; + try { + result = transformSync(esbuild, code, options); + } catch (error) { + throw toSyntaxError(error, code, filename); + } + return { code: result.code, map: JSON.parse(result.map) }; +} + +function transformOptions(filename: string, isModule: boolean, useDefineForClassFields: boolean, jsx: JsxOptions | undefined): esbuild.TransformOptions { + const ext = path.extname(filename); + const jsxMode = jsx?.jsx ?? 'react-jsx'; + return { + sourcefile: filename, + loader: ext === '.tsx' ? 'tsx' : ['.ts', '.mts', '.cts'].includes(ext) ? 'ts' : 'jsx', + format: isModule ? 'esm' : 'cjs', + platform: 'node', + target: 'node' + process.versions.node.split('-')[0], + sourcemap: 'both', + jsx: jsxMode === 'react' ? 'transform' : 'automatic', + jsxDev: jsxMode === 'react-jsxdev', + jsxFactory: jsx?.jsxFactory, + jsxFragment: jsx?.jsxFragmentFactory, + jsxImportSource: jsx?.jsxImportSource, + tsconfigRaw: { + compilerOptions: { + useDefineForClassFields, + alwaysStrict: true, + }, + }, + logLevel: 'silent', + }; +} + +function transformSync(esbuild: Esbuild, code: string, options: esbuild.TransformOptions): esbuild.TransformResult { + try { + return esbuild.transformSync(code, options); + } catch (error) { + // Ctrl+C terminates esbuild's service process along with the whole process group. + if (!(error instanceof Error) || !/^The service (is no longer running|was stopped)/.test(error.message)) + throw error; + void esbuild.stop(); + return esbuild.transformSync(code, options); + } +} + +function toSyntaxError(error: any, code: string, filename: string): Error { + const message: esbuild.Message | undefined = error?.errors?.[0]; + if (!message?.location) + return error; + const { line, column } = message.location; + const { codeFrameColumns } = require(libPath('transform', 'babelBundle')); + const codeFrame = codeFrameColumns(code, { start: { line, column: column + 1 } }); + const syntaxError = new SyntaxError(`${filename}: ${message.text} (${line}:${column})\n\n${codeFrame}`); + (syntaxError as any).loc = { line, column }; + return syntaxError; +} diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index ae1d8a3c72af0..0ee5f6e4fa8af 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -24,6 +24,7 @@ import { loadTsConfig } from './tsconfig-loader'; import { libPath, packageJSON } from '../package'; import { createFileMatcher, debugTest, fileIsModule, resolveImportSpecifierAfterMapping } from '../util'; import * as cc from './compilationCache'; +import { esbuildTransform, loadEsbuild } from './esbuildTransform'; import * as esmLoaderSync from './esmLoaderSync'; import { addHook } from './pirates'; import { PortTransport } from './portTransport'; @@ -230,7 +231,9 @@ export function shouldTransform(filename: string): boolean { } export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } { + const esbuild = process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD ? loadEsbuild() : undefined; const hasPreprocessor = + !esbuild && process.env.PW_TEST_SOURCE_TRANSFORM && process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE && process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE.split(pathSeparator).some(f => filename.startsWith(f)); @@ -239,11 +242,17 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const jsx = tsconfigs.find(tsconfig => tsconfig.jsx)?.jsx; // Without "target", tsc defaults to the latest one, which defines class fields. const useDefineForClassFields = tsconfigs.find(tsconfig => tsconfig.useDefineForClassFields !== undefined)?.useDefineForClassFields ?? true; - const hash = calculateHash(originalCode, filename, !!moduleUrl, pluginsEpilogue, useDefineForClassFields, jsx); + const hash = calculateHash(originalCode, filename, !!moduleUrl, pluginsEpilogue, useDefineForClassFields, jsx, esbuild?.version); const { cachedCode, addToCache, serializedCache } = cc.getFromCompilationCache(filename, hash, moduleUrl); if (cachedCode !== undefined) return { code: cachedCode, serializedCache }; + if (esbuild) { + const { code, map } = esbuildTransform(esbuild, originalCode, filename, !!moduleUrl, useDefineForClassFields, jsx); + const added = addToCache!(code, map); + return { code, serializedCache: added.serializedCache }; + } + // We don't use any browserslist data, but babel checks it anyway. // Silence the annoying warning. process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'; @@ -257,7 +266,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? return { code, serializedCache: added.serializedCache }; } -function calculateHash(content: string, filePath: string, isModule: boolean, pluginsEpilogue: BabelPlugin[], useDefineForClassFields: boolean, jsx: JsxOptions | undefined): string { +function calculateHash(content: string, filePath: string, isModule: boolean, pluginsEpilogue: BabelPlugin[], useDefineForClassFields: boolean, jsx: JsxOptions | undefined, esbuildVersion: string | undefined): string { const hash = crypto.createHash('sha1') .update(isModule ? 'esm' : 'no_esm') .update(content) @@ -266,6 +275,7 @@ function calculateHash(content: string, filePath: string, isModule: boolean, plu .update(pluginsEpilogue.map(p => p[0]).join(',')) .update(useDefineForClassFields ? 'define' : 'assign') .update(jsx ? JSON.stringify(jsx) : '') + .update(esbuildVersion ? 'esbuild@' + esbuildVersion : '') .digest('hex'); return hash; } diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 18b251dbc234f..bc9225a46cb45 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -432,7 +432,7 @@ test('should show params and return value', async ({ showTraceViewer }) => { /Evaluate/, /start:[\d\.]+m?s/, /duration:[\d]+ms/, - /expression:"\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/, + /expression:"\({[↵\s]*a[↵\s]*}\) => {↵\s*console\.log\(['"]Info['"]\);↵\s*console\.warn\(['"]Warning['"]\);↵\s*console/, 'isFunction:true', 'arg:{"a":"paramA","b":4}', 'value:"return paramA"' diff --git a/tests/page/page-evaluate-callback.spec.ts b/tests/page/page-evaluate-callback.spec.ts index 1c4edf7100f22..9862dbe69d8cd 100644 --- a/tests/page/page-evaluate-callback.spec.ts +++ b/tests/page/page-evaluate-callback.spec.ts @@ -19,7 +19,7 @@ import { test as it, expect } from './pageTest'; it('should throw without the exposeFunctions option', async ({ page }) => { await expect(page.evaluate(({ cb }) => cb(), { cb: () => {} })) - .rejects.toThrow(/Attempting to serialize unexpected value at position "cb": \(\) => {}/); + .rejects.toThrow(/Attempting to serialize unexpected value at position "cb": \(\) => {\s*}/); }); it('should call a function passed as an argument', async ({ page }) => { diff --git a/tests/page/page-evaluate.spec.ts b/tests/page/page-evaluate.spec.ts index 30cd7a3462c93..3744ac57eaa51 100644 --- a/tests/page/page-evaluate.spec.ts +++ b/tests/page/page-evaluate.spec.ts @@ -482,22 +482,22 @@ it('should throw for too deep reference chain 2', { it('should throw usable message for unserializable shallow function', async ({ page }) => { await expect(() => page.evaluate(arg => arg, () => { })) - .rejects.toThrow(/Attempting to serialize unexpected value: \(\) => {}/); + .rejects.toThrow(/Attempting to serialize unexpected value: \(\) => {\s*}/); }); it('should throw usable message for unserializable object one deep function', async ({ page }) => { await expect(() => page.evaluate(arg => arg, { aProperty: () => { } })) - .rejects.toThrow(/Attempting to serialize unexpected value at position "aProperty": \(\) => {}/); + .rejects.toThrow(/Attempting to serialize unexpected value at position "aProperty": \(\) => {\s*}/); }); it('should throw usable message for unserializable object nested function', async ({ page }) => { await expect(() => page.evaluate(arg => arg, { a: { inner: { property: () => { } } } })) - .rejects.toThrow(/Attempting to serialize unexpected value at position "a\.inner\.property": \(\) => {}/); + .rejects.toThrow(/Attempting to serialize unexpected value at position "a\.inner\.property": \(\) => {\s*}/); }); it('should throw usable message for unserializable array nested function', async ({ page }) => { await expect(() => page.evaluate(arg => arg, { a: { inner: ['firstValue', { property: () => { } }] } })) - .rejects.toThrow(/Attempting to serialize unexpected value at position "a\.inner\[1\]\.property": \(\) => {}/); + .rejects.toThrow(/Attempting to serialize unexpected value at position "a\.inner\[1\]\.property": \(\) => {\s*}/); }); it('should alias Window, Document and Node', async ({ page }) => { diff --git a/tests/playwright-test/babel.spec.ts b/tests/playwright-test/babel.spec.ts index 6d6c1bfa8bd5e..ebb480c910c91 100644 --- a/tests/playwright-test/babel.spec.ts +++ b/tests/playwright-test/babel.spec.ts @@ -234,6 +234,7 @@ for (const type of ['module', undefined]) { description: 'https://github.com/microsoft/playwright/issues/32659' } }, async ({ runInlineTest }) => { + test.skip(type === 'module' && !!process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD, 'esbuild drops "assert" import attributes'); const result = await runInlineTest({ 'playwright.config.ts': ` import packageJSON from './package.json' assert { type: 'json' }; @@ -257,6 +258,7 @@ for (const type of ['module', undefined]) { } test('should allow overwriting exports of a transpiled module', async ({ runInlineTest }) => { + test.skip(!!process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD, 'esbuild exports are getters'); const result = await runInlineTest({ 'util.ts': ` export function greet() { return 'real'; } @@ -281,6 +283,7 @@ test('should allow overwriting exports of a transpiled module', async ({ runInli }); test('should read undefined from an uninitialized import in a cycle', async ({ runInlineTest }) => { + test.skip(!!process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD, 'esbuild throws on uninitialized imports'); const result = await runInlineTest({ 'a.ts': ` import './b'; @@ -306,6 +309,7 @@ test('should read undefined from an uninitialized import in a cycle', async ({ r }); test('should parse generic arrow functions in tsx', async ({ runInlineTest }) => { + test.skip(!!process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD, 'esbuild does not parse generic arrow functions in tsx'); const result = await runInlineTest({ 'example.spec.tsx': ` import { test, expect } from '@playwright/test'; @@ -322,6 +326,7 @@ test('should parse generic arrow functions in tsx', async ({ runInlineTest }) => }); test('should reject import.meta in commonjs', async ({ runInlineTest }) => { + test.skip(!!process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD, 'esbuild replaces import.meta with {}'); const result = await runInlineTest({ 'example.spec.ts': ` import { test, expect } from '@playwright/test'; diff --git a/tests/playwright-test/esbuild.spec.ts b/tests/playwright-test/esbuild.spec.ts new file mode 100644 index 0000000000000..15421eb5f974e --- /dev/null +++ b/tests/playwright-test/esbuild.spec.ts @@ -0,0 +1,210 @@ +/** + * 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 { test, expect, parseTestRunnerOutput } from './playwright-test-fixtures'; + +const env = { PLAYWRIGHT_EXPERIMENTAL_ESBUILD: '1' }; + +test('should transpile typescript', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + + enum Direction { Up = 1, Down } + namespace Shapes { export const sides = 4; } + namespace Shapes { export const corners = sides; } + + function tagged(value: any, context: ClassMethodDecoratorContext) { + return function (this: any) { return 'tagged:' + value.call(this); }; + } + + class Base { + page: string; + constructor(page: string) { this.page = page; } + } + + class LoginPage extends Base { + declare page: string; + readonly title = this.page + '-title'; + constructor(page: string, private readonly user = 'admin') { super(page); } + @tagged name() { return this.user; } + } + + test('works', () => { + expect(Direction.Down).toBe(2); + expect(Shapes.corners).toBe(4); + const loginPage = new LoginPage('login'); + expect(loginPage.page).toBe('login'); + expect(loginPage.title).toBe('login-title'); + expect(loginPage.name()).toBe('tagged:admin'); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +for (const [compilerOptions, define] of [ + [{}, true], + [{ target: 'ES2021' }, false], +] as const) { + test(`should ${define ? 'define' : 'assign'} class fields with ${JSON.stringify(compilerOptions)}`, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': JSON.stringify({ compilerOptions }), + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + + class Base { + redeclared = 'base'; + setterCalls: string[] = []; + set overridden(value: string) { this.setterCalls.push(value); } + } + + class Derived extends Base { + redeclared: string; + overridden = 'derived'; + fromParameter = this.parameter + '-field'; + constructor(private parameter: string) { super(); } + } + + test('works', () => { + const derived = new Derived('parameter'); + expect(derived.redeclared).toBe(${define ? 'undefined' : `'base'`}); + expect(derived.setterCalls).toEqual(${define ? '[]' : `['derived']`}); + expect(derived.fromParameter).toBe(${define ? `'undefined-field'` : `'parameter-field'`}); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + }); +} + +test('should map locations to sources', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + enum Answer { Yes = 'yes' } + test('fails', () => { + expect(Answer.Yes as string).toBe('no'); + }); + `, + }, { reporter: 'list' }, env); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('a.spec.ts:4:7 › fails'); + expect(result.output).toContain(`> 5 | expect(Answer.Yes as string).toBe('no');`); + expect(result.output).toContain('a.spec.ts:5:38'); +}); + +test('should support ESM packages', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'package.json': `{ "type": "module" }`, + 'playwright.config.ts': ` + import type { PlaywrightTestConfig } from '@playwright/test'; + const config: PlaywrightTestConfig = { projects: [{ name: 'esm' }] }; + export default config; + `, + 'helper.ts': ` + export enum Color { Red = 'red' } + export class Box { constructor(readonly color: Color) {} } + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { Box, Color } from './helper'; + test('works', () => { + expect(new Box(Color.Red).color).toBe('red'); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should respect tsconfig paths', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@lib/*": ["./lib/*"] }, + }, + }`, + 'lib/util.ts': ` + export const value: number = 42; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { value } from '@lib/util'; + test('works', () => { + expect(value).toBe(42); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should respect tsconfig jsx options', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': `{ + "compilerOptions": { "jsx": "react", "jsxFactory": "h" }, + }`, + 'a.spec.tsx': ` + import { test, expect } from '@playwright/test'; + const h = (tag: string, props: any, ...children: any[]) => ({ tag, props, children }); + test('works', () => { + expect(
text
).toEqual({ tag: 'div', props: { id: 'x' }, children: ['text'] }); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should transpile global teardown after sigint', async ({ interactWithTestRunner }) => { + test.skip(process.platform === 'win32', 'No sending SIGINT on Windows'); + + const testProcess = await interactWithTestRunner({ + 'playwright.config.ts': ` + export default { + globalSetup: './globalSetup.ts', + globalTeardown: './globalTeardown.ts', + }; + `, + 'globalSetup.ts': ` + export default () => { + console.log('Global setup'); + console.log('%%SEND-SIGINT%%'); + return new Promise(f => setTimeout(f, 30000)); + }; + `, + 'globalTeardown.ts': ` + export default (): void => { + console.log('Global teardown'); + }; + `, + 'a.spec.ts': ` + import { test } from '@playwright/test'; + test('test', async () => {}); + `, + }, { workers: 1 }, env); + await testProcess.waitForOutput('%%SEND-SIGINT%%'); + process.kill(-testProcess.process.pid!, 'SIGINT'); + const { exitCode } = await testProcess.exited; + expect(exitCode).toBe(130); + + const result = parseTestRunnerOutput(testProcess.output); + expect(result.output).toContain('Global teardown'); +}); diff --git a/tests/playwright-test/list-mode.spec.ts b/tests/playwright-test/list-mode.spec.ts index 1388b38dd9e30..ea052d3adada6 100644 --- a/tests/playwright-test/list-mode.spec.ts +++ b/tests/playwright-test/list-mode.spec.ts @@ -193,7 +193,7 @@ test('should report errors with location', async ({ runInlineTest }) => { expect(JSON.parse(result.outputLines[0])).toEqual({ file: expect.stringContaining('a.test.js'), line: 3, - column: 9, + column: expect.any(Number), }); }); diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 8b36bbcaf3a79..96d2bb98b7e6d 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -63,11 +63,11 @@ test('should return the location of a syntax error with deep stack', async ({ ru `, }); expect(result.exitCode).toBe(1); - expect(result.output).toContain('qux.ts:2:7'); - expect(result.output).toContain('baz.ts:2:7'); - expect(result.output).toContain('bar.ts:2:7'); - expect(result.output).toContain('foo.ts:2:7'); - expect(result.output).toContain('test.spec.ts:2:7'); + expect(result.output).toContain('qux.ts:2:'); + expect(result.output).toContain('baz.ts:2:'); + expect(result.output).toContain('bar.ts:2:'); + expect(result.output).toContain('foo.ts:2:'); + expect(result.output).toContain('test.spec.ts:2:'); }); test('should print an improper error', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/test-server.spec.ts b/tests/playwright-test/test-server.spec.ts index 9febdbe66a8b8..9d1fe101337e8 100644 --- a/tests/playwright-test/test-server.spec.ts +++ b/tests/playwright-test/test-server.spec.ts @@ -208,7 +208,7 @@ test('find related test files errors', async ({ startTestServer, writeFiles }) = const aSpecTs = test.info().outputPath('a.spec.ts'); const result = await testServerConnection.findRelatedTestFiles({ files: [aSpecTs] }); expect(result).toEqual({ testFiles: [], errors: [ - expect.objectContaining({ message: expect.stringContaining(`Identifier 'a' has already been declared`) }), + expect.objectContaining({ message: expect.stringContaining(process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD ? `The symbol "a" has already been declared` : `Identifier 'a' has already been declared`) }), expect.objectContaining({ message: expect.stringContaining(`No tests found`) }), ] }); diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index e0d12e63409dd..7f9256aeed506 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -29,7 +29,7 @@ test('should print load errors', async ({ runUITest }) => { `, }); await page.getByTitle('Toggle output').click(); - await expect(page.getByTestId('output')).toContainText(`Unexpected reserved word 'await'`); + await expect(page.getByTestId('output')).toContainText(process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD ? `"await" can only be used inside an "async" function` : `Unexpected reserved word 'await'`); }); test('should work after theme switch', async ({ runUITest, writeFiles }) => { @@ -190,12 +190,12 @@ test('should format console messages in page', async ({ runUITest }, testInfo) = await expect(page.locator('.console-tab')).toMatchAriaSnapshot(` - listbox: - - option /:1 Object/ - - option /:4 Date/ - - option /:5 Regex/ - - option /:6 Number 0 one 2/ - - option /:7 Download the React DevTools/ - - option /:8 Array of values/ + - option /:\\d+ Object/ + - option /:\\d+ Date/ + - option /:\\d+ Regex/ + - option /:\\d+ Number 0 one 2/ + - option /:\\d+ Download the React DevTools/ + - option /:\\d+ Array of values/ - option /Failed to load resource/ `); diff --git a/tests/playwright-test/ui-mode-test-source.spec.ts b/tests/playwright-test/ui-mode-test-source.spec.ts index a22a142587863..21c49984c3ac9 100644 --- a/tests/playwright-test/ui-mode-test-source.spec.ts +++ b/tests/playwright-test/ui-mode-test-source.spec.ts @@ -154,7 +154,7 @@ test('should show syntax errors in file', async ({ runUITest }) => { await expect( page.locator('.CodeMirror-linewidget') ).toHaveText([ - /Missing semicolon./ + process.env.PLAYWRIGHT_EXPERIMENTAL_ESBUILD ? /Expected ";" but found "&"/ : /Missing semicolon./ ]); }); diff --git a/utils/build/build.js b/utils/build/build.js index 2a8d24c64f2d8..bd57605768304 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -755,6 +755,7 @@ steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); external: [ 'playwright-core', 'playwright-core/*', + 'esbuild', '../package', '../globals', '../transform/esmLoader.js', @@ -813,6 +814,7 @@ steps.push(new EsbuildStep({ 'playwright-core', 'playwright-core/*', 'playwright', + 'esbuild', '../globals', '../package', '../utils', From 26f2d08cd51c617d777b9c7434876a85c35df44c Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 12 Oct 2026 16:56:21 -0700 Subject: [PATCH 2/2] test: trim the esbuild tests to the mainstream cases --- tests/playwright-test/esbuild.spec.ts | 117 +++++++++----------------- 1 file changed, 41 insertions(+), 76 deletions(-) diff --git a/tests/playwright-test/esbuild.spec.ts b/tests/playwright-test/esbuild.spec.ts index 15421eb5f974e..4e3d2df2811f8 100644 --- a/tests/playwright-test/esbuild.spec.ts +++ b/tests/playwright-test/esbuild.spec.ts @@ -20,6 +20,9 @@ const env = { PLAYWRIGHT_EXPERIMENTAL_ESBUILD: '1' }; test('should transpile typescript', async ({ runInlineTest }) => { const result = await runInlineTest({ + 'tsconfig.json': `{ + "compilerOptions": { "jsx": "react", "jsxFactory": "h" }, + }`, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; @@ -52,46 +55,48 @@ test('should transpile typescript', async ({ runInlineTest }) => { expect(loginPage.name()).toBe('tagged:admin'); }); `, + 'b.spec.tsx': ` + import { test, expect } from '@playwright/test'; + const h = (tag: string, props: any, ...children: any[]) => ({ tag, props, children }); + test('jsx', () => { + expect(
text
).toEqual({ tag: 'div', props: { id: 'x' }, children: ['text'] }); + }); + `, }, {}, env); expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); + expect(result.passed).toBe(2); }); -for (const [compilerOptions, define] of [ - [{}, true], - [{ target: 'ES2021' }, false], -] as const) { - test(`should ${define ? 'define' : 'assign'} class fields with ${JSON.stringify(compilerOptions)}`, async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'tsconfig.json': JSON.stringify({ compilerOptions }), - 'a.spec.ts': ` - import { test, expect } from '@playwright/test'; - - class Base { - redeclared = 'base'; - setterCalls: string[] = []; - set overridden(value: string) { this.setterCalls.push(value); } - } - - class Derived extends Base { - redeclared: string; - overridden = 'derived'; - fromParameter = this.parameter + '-field'; - constructor(private parameter: string) { super(); } - } - - test('works', () => { - const derived = new Derived('parameter'); - expect(derived.redeclared).toBe(${define ? 'undefined' : `'base'`}); - expect(derived.setterCalls).toEqual(${define ? '[]' : `['derived']`}); - expect(derived.fromParameter).toBe(${define ? `'undefined-field'` : `'parameter-field'`}); - }); - `, - }, {}, env); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - }); -} +test('should assign class fields for older targets', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ES2021' } }), + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + + class Base { + redeclared = 'base'; + setterCalls: string[] = []; + set overridden(value: string) { this.setterCalls.push(value); } + } + + class Derived extends Base { + redeclared: string; + overridden = 'derived'; + fromParameter = this.parameter + '-field'; + constructor(private parameter: string) { super(); } + } + + test('works', () => { + const derived = new Derived('parameter'); + expect(derived.redeclared).toBe('base'); + expect(derived.setterCalls).toEqual(['derived']); + expect(derived.fromParameter).toBe('parameter-field'); + }); + `, + }, {}, env); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); test('should map locations to sources', async ({ runInlineTest }) => { const result = await runInlineTest({ @@ -133,46 +138,6 @@ test('should support ESM packages', async ({ runInlineTest }) => { expect(result.passed).toBe(1); }); -test('should respect tsconfig paths', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'tsconfig.json': `{ - "compilerOptions": { - "baseUrl": ".", - "paths": { "@lib/*": ["./lib/*"] }, - }, - }`, - 'lib/util.ts': ` - export const value: number = 42; - `, - 'a.spec.ts': ` - import { test, expect } from '@playwright/test'; - import { value } from '@lib/util'; - test('works', () => { - expect(value).toBe(42); - }); - `, - }, {}, env); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); - -test('should respect tsconfig jsx options', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'tsconfig.json': `{ - "compilerOptions": { "jsx": "react", "jsxFactory": "h" }, - }`, - 'a.spec.tsx': ` - import { test, expect } from '@playwright/test'; - const h = (tag: string, props: any, ...children: any[]) => ({ tag, props, children }); - test('works', () => { - expect(
text
).toEqual({ tag: 'div', props: { id: 'x' }, children: ['text'] }); - }); - `, - }, {}, env); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); - test('should transpile global teardown after sigint', async ({ interactWithTestRunner }) => { test.skip(process.platform === 'win32', 'No sending SIGINT on Windows');