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..4e3d2df2811f8 --- /dev/null +++ b/tests/playwright-test/esbuild.spec.ts @@ -0,0 +1,175 @@ +/** + * 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({ + 'tsconfig.json': `{ + "compilerOptions": { "jsx": "react", "jsxFactory": "h" }, + }`, + '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'); + }); + `, + 'b.spec.tsx': ` + import { test, expect } from '@playwright/test'; + const h = (tag: string, props: any, ...children: any[]) => ({ tag, props, children }); + test('jsx', () => { + expect(