From 274d4834b23fbcadc7bd3c0ec6797905ceec7f86 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 9 Oct 2026 16:17:59 -0700 Subject: [PATCH 1/3] feat(test-runner): respect useDefineForClassFields from tsconfig --- docs/src/test-typescript-js.md | 16 +++++++- .../playwright/src/transform/babelBundle.ts | 33 ++++++++++----- .../playwright/src/transform/transform.ts | 20 ++++++++-- .../src/transform/tsconfig-loader.ts | 8 ++++ tests/playwright-test/babel.spec.ts | 40 +++++++++++++++++++ 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index d607830af6184..91946e8c6ec1c 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -28,7 +28,7 @@ npx tsc -p tsconfig.json --noEmit -w ## tsconfig.json -Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `paths`, `jsx`, `jsxFactory`, `jsxFragmentFactory`, `jsxImportSource`, `references` and `extends`. +Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `paths`, `jsx`, `jsxFactory`, `jsxFragmentFactory`, `jsxImportSource`, `useDefineForClassFields`, `references` and `extends`. The `target` option is only used to determine `useDefineForClassFields`. We recommend setting up a separate `tsconfig.json` in the tests directory so that you can change some preferences specifically for the tests. Here is an example directory structure. @@ -86,6 +86,20 @@ Playwright compiles JSX in `.tsx` and `.jsx` files according to the `jsx`, `jsxF } ``` +### tsconfig class fields + +Playwright compiles TypeScript class fields according to the `useDefineForClassFields` option in the `tsconfig.json`. Just like `tsc`, when it is not specified, it is enabled for `ES2022` and later targets, and when `target` is not specified. + +With `useDefineForClassFields` enabled, class fields follow the JavaScript semantics, so field initializers run before the constructor body and can not access constructor parameter properties. Disable it to keep the legacy TypeScript semantics. + +```json title="tsconfig.json" +{ + "compilerOptions": { + "useDefineForClassFields": false + } +} +``` + ### tsconfig resolution By default, Playwright will look up a closest tsconfig for each imported file by going up the directory structure and looking for `tsconfig.json` or `jsconfig.json`. This way, you can create a `tests/tsconfig.json` file that will be used only for your tests and Playwright will pick it up automatically. diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index eae34054b49bf..686a6c0774758 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -36,7 +36,7 @@ export type JsxOptions = { jsxFragmentFactory?: string; jsxImportSource?: string; }; -export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsSuffix: BabelPlugin[], jsx?: JsxOptions) => BabelFileResult | null; +export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsSuffix: BabelPlugin[], useDefineForClassFields: boolean, jsx?: JsxOptions) => BabelFileResult | null; const nodeMajorVersion = +process.versions.node.split('.')[0]; @@ -57,25 +57,29 @@ function jsxPlugin(jsx: JsxOptions | undefined): [any, any] { }]; } -function babelTransformOptions(isTypeScript: boolean, isModule: boolean, pluginsEpilogue: [string, any?][], jsx?: JsxOptions): TransformOptions { +function babelTransformOptions(isTypeScript: boolean, isModule: boolean, pluginsEpilogue: [string, any?][], useDefineForClassFields: boolean, jsx?: JsxOptions): TransformOptions { const plugins = [ [require('@babel/plugin-syntax-import-attributes'), { deprecatedAssertSyntax: true }], ]; if (isTypeScript) { plugins.push( - // Strip "declare" class fields before these plugins run: + // Strip "declare" class fields, and uninitialized ones that tsc omits without + // "useDefineForClassFields", before these plugins run: // - plugin-proposal-decorators // - plugin-transform-class-properties // - plugin-transform-private-methods // See https://github.com/microsoft/playwright/issues/38586 [ (): PluginObj => ({ - name: 'strip-declare-class-fields', + name: 'strip-type-only-class-fields', visitor: { Class(path) { for (const member of path.get('body.body')) { - if (member.isClassProperty() && member.node.declare) + if (!member.isClassProperty()) + continue; + const { declare, value, decorators } = member.node; + if (declare || (!useDefineForClassFields && !value && !decorators?.length)) member.remove(); } } @@ -83,13 +87,20 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins }) ], [require('@babel/plugin-proposal-decorators'), { version: '2023-05' }], - [require('@babel/plugin-transform-class-properties')], + ); + // With "useDefineForClassFields", Node defines class fields natively. + if (!useDefineForClassFields) { + plugins.push( + [require('@babel/plugin-transform-class-properties')], + [require('@babel/plugin-transform-private-methods')], + ); + } + plugins.push( [require('@babel/plugin-transform-class-static-block')], [require('@babel/plugin-transform-numeric-separator')], [require('@babel/plugin-transform-logical-assignment-operators')], [require('@babel/plugin-transform-nullish-coalescing-operator')], [require('@babel/plugin-transform-optional-chaining')], - [require('@babel/plugin-transform-private-methods')], [require('@babel/plugin-syntax-json-strings')], [require('@babel/plugin-syntax-optional-catch-binding')], [require('@babel/plugin-syntax-async-generators')], @@ -163,7 +174,7 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins setPublicClassFields: true, }, presets: isTypeScript ? [ - [require('@babel/preset-typescript'), { onlyRemoveTypeImports: false }], + [require('@babel/preset-typescript'), { onlyRemoveTypeImports: false, allowDeclareFields: useDefineForClassFields }], ] : [], plugins: [ ...plugins, @@ -180,14 +191,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, pluginsEpilogue: [string, any?][], jsx?: JsxOptions): BabelFileResult | null { +export function babelTransform(code: string, filename: string, isModule: boolean, pluginsEpilogue: [string, any?][], useDefineForClassFields: boolean, jsx?: JsxOptions): BabelFileResult | null { if (isTransforming) return null; // Prevent reentry while requiring plugins lazily. isTransforming = true; try { - const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsEpilogue, jsx); + const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsEpilogue, useDefineForClassFields, jsx); return babel.transform(code, { filename, ...options }); } finally { isTransforming = false; @@ -195,6 +206,6 @@ export function babelTransform(code: string, filename: string, isModule: boolean } export function babelParse(code: string, filename: string, isModule: boolean): babel.ParseResult { - const options = babelTransformOptions(isTypeScript(filename), isModule, []); + const options = babelTransformOptions(isTypeScript(filename), isModule, [], true); return babel.parse(code, { filename, ...options })!; } diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 63bb52fdd47e5..b2996ba45e8e6 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -41,6 +41,7 @@ type ParsedTsConfigData = { paths: { key: string, values: string[] }[]; allowJs: boolean; jsx?: JsxOptions; + useDefineForClassFields?: boolean; }; const cachedTSConfigs = new Map(); @@ -86,9 +87,16 @@ function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { jsxFragmentFactory: tsconfig.jsxFragmentFactory, jsxImportSource: tsconfig.jsxImportSource, } : undefined, + useDefineForClassFields: tsconfig.useDefineForClassFields ?? (tsconfig.target ? targetUsesDefineForClassFields(tsconfig.target) : undefined), }; } +function targetUsesDefineForClassFields(target: string): boolean { + const normalized = target.toLowerCase(); + const year = normalized.match(/^es(\d{4})$/)?.[1]; + return normalized === 'esnext' || (!!year && +year >= 2022); +} + function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] { if (_singleTSConfigPath && !_singleTSConfig) _singleTSConfig = loadTsConfig(_singleTSConfigPath).map(validateTsConfig); @@ -221,8 +229,11 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE && process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE.split(pathSeparator).some(f => filename.startsWith(f)); const pluginsEpilogue = hasPreprocessor ? [[process.env.PW_TEST_SOURCE_TRANSFORM!]] as BabelPlugin[] : []; - const jsx = loadAndValidateTsconfigsForFile(filename).find(tsconfig => tsconfig.jsx)?.jsx; - const hash = calculateHash(originalCode, filename, !!moduleUrl, pluginsEpilogue, jsx); + const tsconfigs = loadAndValidateTsconfigsForFile(filename); + 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 { cachedCode, addToCache, serializedCache } = cc.getFromCompilationCache(filename, hash, moduleUrl); if (cachedCode !== undefined) return { code: cachedCode, serializedCache }; @@ -232,7 +243,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'; const { babelTransform }: { babelTransform: BabelTransformFunction } = require(libPath('transform', 'babelBundle')); - const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsEpilogue, jsx); + const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsEpilogue, useDefineForClassFields, jsx); if (!babelResult?.code) return { code: originalCode, serializedCache }; const { code, map } = babelResult; @@ -240,13 +251,14 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? return { code, serializedCache: added.serializedCache }; } -function calculateHash(content: string, filePath: string, isModule: boolean, pluginsEpilogue: BabelPlugin[], jsx: JsxOptions | undefined): string { +function calculateHash(content: string, filePath: string, isModule: boolean, pluginsEpilogue: BabelPlugin[], useDefineForClassFields: boolean, jsx: JsxOptions | undefined): string { const hash = crypto.createHash('sha1') .update(isModule ? 'esm' : 'no_esm') .update(content) .update(filePath) .update(version) .update(pluginsEpilogue.map(p => p[0]).join(',')) + .update(useDefineForClassFields ? 'define' : 'assign') .update(jsx ? JSON.stringify(jsx) : '') .digest('hex'); return hash; diff --git a/packages/playwright/src/transform/tsconfig-loader.ts b/packages/playwright/src/transform/tsconfig-loader.ts index b9f71b5fade6f..ebbd21a2140b3 100644 --- a/packages/playwright/src/transform/tsconfig-loader.ts +++ b/packages/playwright/src/transform/tsconfig-loader.ts @@ -41,6 +41,8 @@ interface TsConfig { jsxFactory?: string; jsxFragmentFactory?: string; jsxImportSource?: string; + target?: string; + useDefineForClassFields?: boolean; }; references?: { path: string }[]; } @@ -57,6 +59,8 @@ export interface LoadedTsConfig { jsxFactory?: string; jsxFragmentFactory?: string; jsxImportSource?: string; + target?: string; + useDefineForClassFields?: boolean; } const jsxOptionNames = ['jsx', 'jsxFactory', 'jsxFragmentFactory', 'jsxImportSource'] as const; @@ -139,6 +143,10 @@ function innerLoadTsConfig( if (parsedConfig.compilerOptions?.[name] !== undefined) result[name] = parsedConfig.compilerOptions[name]; } + if (parsedConfig.compilerOptions?.target !== undefined) + result.target = parsedConfig.compilerOptions.target; + if (parsedConfig.compilerOptions?.useDefineForClassFields !== undefined) + result.useDefineForClassFields = parsedConfig.compilerOptions.useDefineForClassFields; if (parsedConfig.compilerOptions?.paths !== undefined) { // We must store pathsBasePath from the config that defines "paths" and later resolve // based on this absolute path, when no "baseUrl" is specified. See tsc for reference: diff --git a/tests/playwright-test/babel.spec.ts b/tests/playwright-test/babel.spec.ts index 5843ab7384f7f..e5254f6e9b117 100644 --- a/tests/playwright-test/babel.spec.ts +++ b/tests/playwright-test/babel.spec.ts @@ -110,6 +110,7 @@ test('should treat enums equally', async ({ runInlineTest }) => { test('should be able to access |this| inside class properties', async ({ runInlineTest }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21794' }); const result = await runInlineTest({ + 'tsconfig.json': `{ "compilerOptions": { "useDefineForClassFields": false } }`, 'example.spec.ts': ` import { test, expect } from '@playwright/test'; @@ -127,6 +128,45 @@ test('should be able to access |this| inside class properties', async ({ runInli expect(result.passed).toBe(1); }); +for (const [compilerOptions, define] of [ + [{}, true], + [{ target: 'ES2022' }, true], + [{ target: 'ES2021' }, false], + [{ target: 'ES2021', useDefineForClassFields: true }, true], + [{ target: 'ESNext', useDefineForClassFields: false }, 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 }), + 'example.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'`}); + }); + `, + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + }); +} + test('should work with |const| Type Parameters', async ({ runInlineTest }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21900' }); const result = await runInlineTest({ From 5eb4bdad7b2454a880400937e0666201a51e52a6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 6 Oct 2026 15:36:06 -0700 Subject: [PATCH 2/3] feat(test-runner): experimental esbuild transpilation Opt in with PLAYWRIGHT_EXPERIMENTAL_ESBUILD=1. Requires esbuild to be installed by the user, declared as an optional peer dependency. --- 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/esbuild.spec.ts | 174 ++++++++++++++++++ utils/build/build.js | 2 + 9 files changed, 303 insertions(+), 35 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..f0edf9b152ab9 --- /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, jsx: JsxOptions | undefined): { code: string, map: any } { + const options = transformOptions(filename, isModule, 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, 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: false, + 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 b2996ba45e8e6..0c2c40856c6a6 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'; @@ -224,7 +225,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)); @@ -233,11 +236,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, 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'; @@ -251,7 +260,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) @@ -260,6 +269,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/esbuild.spec.ts b/tests/playwright-test/esbuild.spec.ts new file mode 100644 index 0000000000000..083294ce17298 --- /dev/null +++ b/tests/playwright-test/esbuild.spec.ts @@ -0,0 +1,174 @@ +/** + * 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); +}); + +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/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 e2a65084bfeea533bb13f5571346780ff910c644 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 12 Oct 2026 08:55:26 -0700 Subject: [PATCH 3/3] feat(test-runner): respect useDefineForClassFields in esbuild mode --- .../src/transform/esbuildTransform.ts | 8 ++--- .../playwright/src/transform/transform.ts | 2 +- tests/playwright-test/esbuild.spec.ts | 36 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/playwright/src/transform/esbuildTransform.ts b/packages/playwright/src/transform/esbuildTransform.ts index f0edf9b152ab9..0e26d0e000d10 100644 --- a/packages/playwright/src/transform/esbuildTransform.ts +++ b/packages/playwright/src/transform/esbuildTransform.ts @@ -31,8 +31,8 @@ export function loadEsbuild(): Esbuild { } } -export function esbuildTransform(esbuild: Esbuild, code: string, filename: string, isModule: boolean, jsx: JsxOptions | undefined): { code: string, map: any } { - const options = transformOptions(filename, isModule, jsx); +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); @@ -42,7 +42,7 @@ export function esbuildTransform(esbuild: Esbuild, code: string, filename: strin return { code: result.code, map: JSON.parse(result.map) }; } -function transformOptions(filename: string, isModule: boolean, jsx: JsxOptions | undefined): esbuild.TransformOptions { +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 { @@ -59,7 +59,7 @@ function transformOptions(filename: string, isModule: boolean, jsx: JsxOptions | jsxImportSource: jsx?.jsxImportSource, tsconfigRaw: { compilerOptions: { - useDefineForClassFields: false, + useDefineForClassFields, alwaysStrict: true, }, }, diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 0c2c40856c6a6..2158b5242d5ae 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -242,7 +242,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? return { code: cachedCode, serializedCache }; if (esbuild) { - const { code, map } = esbuildTransform(esbuild, originalCode, filename, !!moduleUrl, jsx); + const { code, map } = esbuildTransform(esbuild, originalCode, filename, !!moduleUrl, useDefineForClassFields, jsx); const added = addToCache!(code, map); return { code, serializedCache: added.serializedCache }; } diff --git a/tests/playwright-test/esbuild.spec.ts b/tests/playwright-test/esbuild.spec.ts index 083294ce17298..15421eb5f974e 100644 --- a/tests/playwright-test/esbuild.spec.ts +++ b/tests/playwright-test/esbuild.spec.ts @@ -57,6 +57,42 @@ test('should transpile typescript', async ({ runInlineTest }) => { 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': `