From b1e6c00252f45d4215b1612b8c6d48abc93b88e2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 8 Apr 2026 07:41:39 -0700 Subject: [PATCH] chore: bundle esm loader --- .../playwright-ct-core/src/tsxTransform.ts | 9 ++++--- packages/playwright/src/common/DEPS.list | 2 +- packages/playwright/src/common/config.ts | 3 ++- .../playwright/src/common/esmLoaderHost.ts | 2 +- .../playwright/src/common/expectBundle.ts | 3 ++- packages/playwright/src/index.ts | 3 ++- packages/playwright/src/package.ts | 26 +++++++++++++++++++ packages/playwright/src/program.ts | 3 +-- packages/playwright/src/transform/DEPS.list | 2 ++ .../playwright/src/transform/babelBundle.ts | 15 ++++++----- .../src/transform/compilationCache.ts | 7 ++--- .../playwright/src/transform/transform.ts | 15 +++++++++-- utils/build/build.js | 20 ++++++++++++++ 13 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 packages/playwright/src/package.ts diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 09355b51af21d..ac71ee5ee60ff 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -17,7 +17,6 @@ import path from 'path'; import { declare, traverse, types } from 'playwright/lib/transform/babelBundle'; -import { setTransformData } from 'playwright/lib/transform/transform'; import type { BabelAPI, PluginObj, T } from 'playwright/lib/transform/babelBundle'; const t: typeof T = types; @@ -25,7 +24,11 @@ const t: typeof T = types; let jsxComponentNames: Set; let importInfos: Map; -export default declare((api: BabelAPI) => { +type TsxTransformOptions = { + setTransformData: (key: string, value: any) => void; +}; + +export default declare((api: BabelAPI, options: TsxTransformOptions) => { api.assertVersion(7); const result: PluginObj = { @@ -65,7 +68,7 @@ export default declare((api: BabelAPI) => { ) ); } - setTransformData('playwright-ct-core', [...importInfos.values()]); + options.setTransformData('playwright-ct-core', [...importInfos.values()]); } }, diff --git a/packages/playwright/src/common/DEPS.list b/packages/playwright/src/common/DEPS.list index a1b75b25b6da5..0ac7b0f1c4a9b 100644 --- a/packages/playwright/src/common/DEPS.list +++ b/packages/playwright/src/common/DEPS.list @@ -4,7 +4,7 @@ ../util.ts ../transform ../isomorphic/teleReceiver.ts +../package.ts [testType.ts] ../matchers/expect.ts - diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 2bf09e31e03c7..ada8555271e0b 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -18,6 +18,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { packageJSON } from '../package'; import { getPackageJsonPath, mergeObjects } from '../util'; import type { Config, Fixtures, Metadata, Project, ReporterDescription } from '../../types/test'; @@ -117,7 +118,7 @@ export class FullConfigInternal { tags: globalTags, updateSnapshots: takeFirst(configCLIOverrides.updateSnapshots, userConfig.updateSnapshots, 'missing'), updateSourceMethod: takeFirst(configCLIOverrides.updateSourceMethod, userConfig.updateSourceMethod, 'patch'), - version: require('../../package.json').version, + version: packageJSON.version, workers: resolveWorkers(takeFirst((configCLIOverrides.debug || configCLIOverrides.pause) ? 1 : undefined, configCLIOverrides.workers, userConfig.workers, '50%')), webServer: null, }; diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 456def7bb0694..d2cd0db6c40ed 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -41,7 +41,7 @@ export function registerESMLoader() { const { port1, port2 } = new MessageChannel(); // register will wait until the loader is initialized. - register(url.pathToFileURL(require.resolve('../transform/esmLoader')), { + register(url.pathToFileURL(require.resolve('../esmLoaderBundle.js')), { data: { port: port2 }, transferList: [port2], }); diff --git a/packages/playwright/src/common/expectBundle.ts b/packages/playwright/src/common/expectBundle.ts index 960c55adea79f..620ec16d93212 100644 --- a/packages/playwright/src/common/expectBundle.ts +++ b/packages/playwright/src/common/expectBundle.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export const expect: typeof import('../../bundles/expect/node_modules/expect/build').expect = require('./expectBundleImpl').expect; +import { libPath } from '../package'; +export const expect: typeof import('../../bundles/expect/node_modules/expect/build').expect = require(libPath('common', 'expectBundleImpl')).expect; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 2233871619f98..bb9153757d6da 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -28,6 +28,7 @@ import { setBoxedStackPrefixes } from '@utils/nodePlatform'; import { currentZone } from '@utils/zones'; import { buildErrorContext } from './errorContext'; import { currentTestInfo } from './common/globals'; +import { packageRoot } from './package'; import { rootTestType } from './common/testType'; import { createCustomMessageHandler, runDaemonForContext } from './mcp/test/browserBackend'; @@ -46,7 +47,7 @@ import type { BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracin export { expect } from './matchers/expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; -setBoxedStackPrefixes([path.dirname(require.resolve('../package.json'))]); +setBoxedStackPrefixes([packageRoot]); if ((process as any)['__pw_initiator__']) { const originalStackTraceLimit = Error.stackTraceLimit; diff --git a/packages/playwright/src/package.ts b/packages/playwright/src/package.ts new file mode 100644 index 0000000000000..fdd2c47f25da8 --- /dev/null +++ b/packages/playwright/src/package.ts @@ -0,0 +1,26 @@ +/** + * 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'; + +// Use a dynamic path so esbuild does not statically resolve and inline +// package.json into coreBundle.js. +export const packageRoot = path.join(__dirname, '..'); +export const packageJSON = require(path.join(packageRoot, 'package.json')); + +export function libPath(...parts: string[]): string { + return path.join(packageRoot, 'lib', ...parts); +} diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 3a5a001204aca..030d8359ba5e9 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -27,14 +27,13 @@ import { showReport, mergeReports } from './cli/reportActions'; import { TestServerBackend, testServerBackendTools } from './mcp/test/testBackend'; import { loadConfigFromFile } from './common/configLoader'; import { ClaudeGenerator, OpencodeGenerator, VSCodeGenerator, CopilotGenerator } from './agents/generateAgents'; +import { packageJSON } from './package'; export { program }; import type { TraceMode } from '../types/test'; import type { Command } from 'playwright-core/lib/utilsBundle'; -const packageJSON = require('../package.json'); - libCli.decorateProgram(program); function addTestCommand(program: Command) { diff --git a/packages/playwright/src/transform/DEPS.list b/packages/playwright/src/transform/DEPS.list index bd60d14d99048..b00437f35ca2b 100644 --- a/packages/playwright/src/transform/DEPS.list +++ b/packages/playwright/src/transform/DEPS.list @@ -1,5 +1,7 @@ [*] ../util.ts ../common/globals.ts +../package.ts +@utils/** node_modules/json5 node_modules/source-map-support diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index ce61eecc90a66..c1d051c4a71b3 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -14,15 +14,18 @@ * limitations under the License. */ +import { libPath } from '../package'; + import type { BabelFileResult, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core'; -export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns; -export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare; -export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; -export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; +const babelBundleImpl = require(libPath('transform', 'babelBundleImpl')); +export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = babelBundleImpl.codeFrameColumns; +export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = babelBundleImpl.declare; +export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = babelBundleImpl.types; +export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = babelBundleImpl.traverse; export type BabelPlugin = [string, any?]; export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult | null; -export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; +export const babelTransform: BabelTransformFunction = babelBundleImpl.babelTransform; export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult; -export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse; +export const babelParse: BabelParseFunction = babelBundleImpl.babelParse; export type { NodePath, PluginObj, types as T } from '../../bundles/babel/node_modules/@types/babel__core'; export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils'; diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index cba29b294b45c..760a1378e64f0 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -18,9 +18,10 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { utils } from 'playwright-core/lib/coreBundle'; +import { calculateSha1 } from '@utils/crypto'; import sourceMapSupport from 'source-map-support'; import { isWorkerProcess } from '../common/globals'; +import { packageRoot } from '../package'; export type MemoryCache = { codePath: string; @@ -176,7 +177,7 @@ export function addToCompilationCache(payload: SerializedCompilationCache) { function calculateFilePathHash(filePath: string): string { // Larger file path hash allows for fewer collisions compared to content, as we only check file path collision for deleting files - return utils.calculateSha1(filePath).substring(0, 10); + return calculateSha1(filePath).substring(0, 10); } function calculateCachePath(filePath: string, cacheFolderName: string, hashPrefix: string): string { @@ -279,7 +280,7 @@ export function dependenciesForTestFile(filename: string): Set { // This is only used in the dev mode, specifically excluding // files from packages/playwright*. In production mode, node_modules covers // that. -const kPlaywrightInternalPrefix = path.resolve(__dirname, '../../../playwright'); +const kPlaywrightInternalPrefix = packageRoot; export function belongsToNodeModules(file: string) { if (file.includes(`${path.sep}node_modules${path.sep}`)) diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index e4cc45c5976ba..6fa634cd8a438 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -23,6 +23,7 @@ import crypto from 'crypto'; import sourceMapSupport from 'source-map-support'; import { loadTsConfig } from './tsconfig-loader'; +import { packageJSON } from '../package'; import { createFileMatcher, debugTest, fileIsModule, resolveImportSpecifierAfterMapping } from '../util'; import { belongsToNodeModules, currentFileDepsCollector, getFromCompilationCache, installSourceMapSupport } from './compilationCache'; import { addHook } from './pirates'; @@ -33,7 +34,7 @@ import type { LoadedTsConfig } from './tsconfig-loader'; import type { Matcher } from '../util'; -const version = require('../../package.json').version; +const version = packageJSON.version; type ParsedTsConfigData = { pathsBase?: string; @@ -237,7 +238,17 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); transformData = new Map(); - const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); + // Pass `setTransformData` to plugins via plugin options instead of having + // them import it. The bundled esmLoader inlines its own copy of this file, + // so an import-based approach would close over the wrong `transformData` + // module-level variable. The closure here always references the bundle copy + // currently driving the transform. + const setTransformDataForPlugin = (key: string, value: any) => transformData.set(key, value); + const wrappedPrologue: BabelPlugin[] = pluginsPrologue.map(([name, opts]) => [ + name, + { ...(opts || {}), setTransformData: setTransformDataForPlugin }, + ]); + const babelResult = babelTransform(originalCode, filename, !!moduleUrl, wrappedPrologue, pluginsEpilogue); if (!babelResult?.code) return { code: originalCode, serializedCache }; const { code, map } = babelResult; diff --git a/utils/build/build.js b/utils/build/build.js index ebb116a9af235..b7e495fb37d86 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -669,6 +669,26 @@ function assertCoreBundleHasNoNodeModules() { steps.push(new CustomCallbackStep(assertCoreBundleHasNoNodeModules)); +// playwright/lib/transform/esmLoader2.js — bundled ESM loader registered by +// common/esmLoaderHost.ts via node:module register. Same externalization +// rules as the worker bundle. +{ + const playwrightSrc = filePath('packages/playwright/src'); + steps.push(new EsbuildStep({ + bundle: true, + entryPoints: [filePath('packages/playwright/src/transform/esmLoader.ts')], + outfile: filePath('packages/playwright/lib/esmLoaderBundle.js'), + sourcemap: withSourceMaps ? 'linked' : false, + platform: 'node', + format: 'cjs', + external: [ + 'playwright-core', + 'playwright-core/*', + ], + plugins: [], + }, [playwrightSrc])); +} + // Build the Electron preload loader as a standalone CJS file. It runs inside // the Electron process (via `electron -r loader.js`) and must not depend on // coreBundle. `electron` is resolved at runtime by the Electron process.