From d47ed6a07668da922e4d549bd15371541958a05b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 12 Jan 2024 20:02:27 -0800 Subject: [PATCH] cherry-pick(#28975): chore: refactor import processing in ct --- packages/playwright-ct-core/src/DEPS.list | 2 + .../playwright-ct-core/src/importRegistry.ts | 59 ++++++ packages/playwright-ct-core/src/mount.ts | 116 ++++++----- .../playwright-ct-core/src/tsxTransform.ts | 189 ++++++++---------- packages/playwright-ct-core/src/vitePlugin.ts | 70 +++++-- .../playwright-ct-core/types/component.d.ts | 34 ++-- packages/playwright-ct-react/index.d.ts | 2 +- .../playwright-ct-react/registerSource.mjs | 127 ++---------- .../playwright-ct-react17/registerSource.mjs | 93 ++------- .../playwright-ct-solid/registerSource.mjs | 74 ++----- .../playwright-ct-svelte/registerSource.mjs | 63 ++---- packages/playwright-ct-vue/registerSource.mjs | 91 +++------ .../playwright-ct-vue2/registerSource.mjs | 93 +++------ .../bundles/babel/src/babelBundleImpl.ts | 1 + packages/playwright/jsx-runtime.js | 2 + tests/components/ct-react17/tsconfig.json | 2 +- tests/playwright-test/loader.spec.ts | 2 + .../playwright.ct-build.spec.ts | 33 ++- .../playwright.ct-react.spec.ts | 4 +- 19 files changed, 436 insertions(+), 621 deletions(-) create mode 100644 packages/playwright-ct-core/src/importRegistry.ts diff --git a/packages/playwright-ct-core/src/DEPS.list b/packages/playwright-ct-core/src/DEPS.list index e69de29bb2d1d..dec82b04be5fb 100644 --- a/packages/playwright-ct-core/src/DEPS.list +++ b/packages/playwright-ct-core/src/DEPS.list @@ -0,0 +1,2 @@ +[importRegistry.ts] +../types/** diff --git a/packages/playwright-ct-core/src/importRegistry.ts b/packages/playwright-ct-core/src/importRegistry.ts new file mode 100644 index 0000000000000..641e73991c476 --- /dev/null +++ b/packages/playwright-ct-core/src/importRegistry.ts @@ -0,0 +1,59 @@ +/** + * 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 type { ImportRef } from '../types/component'; + +export class ImportRegistry { + private _registry = new Map Promise>(); + + initialize(components: Record Promise>) { + for (const [name, value] of Object.entries(components)) + this._registry.set(name, value); + } + + async resolveImports(value: any): Promise { + if (value === null || typeof value !== 'object') + return value; + if (this._isImportRef(value)) { + const importFunction = this._registry.get(value.id); + if (!importFunction) + throw new Error(`Unregistered component: ${value.id}. Following components are registered: ${[...this._registry.keys()]}`); + let importedObject = await importFunction(); + if (!importedObject) + throw new Error(`Could not resolve component: ${value.id}.`); + if (value.property) { + importedObject = importedObject[value.property]; + if (!importedObject) + throw new Error(`Could not instantiate component: ${value.id}.${value.property}.`); + } + return importedObject; + } + if (Array.isArray(value)) { + const result = []; + for (const item of value) + result.push(await this.resolveImports(item)); + return result; + } + const result: any = {}; + for (const [key, prop] of Object.entries(value)) + result[key] = await this.resolveImports(prop); + return result; + } + + private _isImportRef(value: any): value is ImportRef { + return typeof value === 'object' && value && value.__pw_type === 'importRef'; + } +} diff --git a/packages/playwright-ct-core/src/mount.ts b/packages/playwright-ct-core/src/mount.ts index d349ba23478ae..0dcf0bcde8a2a 100644 --- a/packages/playwright-ct-core/src/mount.ts +++ b/packages/playwright-ct-core/src/mount.ts @@ -15,7 +15,7 @@ */ import type { Fixtures, Locator, Page, BrowserContextOptions, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, BrowserContext } from 'playwright/test'; -import type { Component, JsxComponent, MountOptions } from '../types/component'; +import type { Component, ImportRef, JsxComponent, MountOptions, ObjectComponentOptions } from '../types/component'; import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config'; let boundCallbacksForMount: Function[] = []; @@ -25,61 +25,65 @@ interface MountResult extends Locator { update(options: Omit | string | JsxComponent): Promise; } -export const fixtures: Fixtures< - PlaywrightTestArgs & PlaywrightTestOptions & { - mount: (component: any, options: any) => Promise; - }, - PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } }, - { _contextFactory: (options?: BrowserContextOptions) => Promise, _contextReuseMode: ContextReuseMode }> = { +type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { + mount: (component: any, options: any) => Promise; +}; +type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } }; +type BaseTestFixtures = { + _contextFactory: (options?: BrowserContextOptions) => Promise, + _contextReuseMode: ContextReuseMode +}; + +export const fixtures: Fixtures = { + + _contextReuseMode: 'when-possible', - _contextReuseMode: 'when-possible', + serviceWorkers: 'block', - serviceWorkers: 'block', + _ctWorker: [{ context: undefined, hash: '' }, { scope: 'worker' }], - _ctWorker: [{ context: undefined, hash: '' }, { scope: 'worker' }], + page: async ({ page }, use, info) => { + if (!((info as any)._configInternal as FullConfigInternal).defineConfigWasUsed) + throw new Error('Component testing requires the use of the defineConfig() in your playwright-ct.config.{ts,js}: https://aka.ms/playwright/ct-define-config'); + await (page as any)._wrapApiCall(async () => { + await page.exposeFunction('__ct_dispatch', (ordinal: number, args: any[]) => { + boundCallbacksForMount[ordinal](...args); + }); + await page.goto(process.env.PLAYWRIGHT_TEST_BASE_URL!); + }, true); + await use(page); + }, - page: async ({ page }, use, info) => { - if (!((info as any)._configInternal as FullConfigInternal).defineConfigWasUsed) - throw new Error('Component testing requires the use of the defineConfig() in your playwright-ct.config.{ts,js}: https://aka.ms/playwright/ct-define-config'); - await (page as any)._wrapApiCall(async () => { - await page.exposeFunction('__ct_dispatch', (ordinal: number, args: any[]) => { - boundCallbacksForMount[ordinal](...args); - }); - await page.goto(process.env.PLAYWRIGHT_TEST_BASE_URL!); + mount: async ({ page }, use) => { + await use(async (componentRef: JsxComponent | ImportRef, options?: ObjectComponentOptions & MountOptions) => { + const selector = await (page as any)._wrapApiCall(async () => { + return await innerMount(page, componentRef, options); }, true); - await use(page); - }, - - mount: async ({ page }, use) => { - await use(async (component: JsxComponent | string, options?: MountOptions) => { - const selector = await (page as any)._wrapApiCall(async () => { - return await innerMount(page, component, options); - }, true); - const locator = page.locator(selector); - return Object.assign(locator, { - unmount: async () => { - await locator.evaluate(async () => { - const rootElement = document.getElementById('root')!; - await window.playwrightUnmount(rootElement); - }); - }, - update: async (options: JsxComponent | Omit) => { - if (isJsxApi(options)) - return await innerUpdate(page, options); - await innerUpdate(page, component, options); - } - }); + const locator = page.locator(selector); + return Object.assign(locator, { + unmount: async () => { + await locator.evaluate(async () => { + const rootElement = document.getElementById('root')!; + await window.playwrightUnmount(rootElement); + }); + }, + update: async (options: JsxComponent | ObjectComponentOptions) => { + if (isJsxComponent(options)) + return await innerUpdate(page, options); + await innerUpdate(page, componentRef, options); + } }); - boundCallbacksForMount = []; - }, - }; + }); + boundCallbacksForMount = []; + }, +}; -function isJsxApi(options: Record): options is JsxComponent { - return options?.kind === 'jsx'; +function isJsxComponent(component: any): component is JsxComponent { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } -async function innerUpdate(page: Page, jsxOrType: JsxComponent | string, options: Omit = {}): Promise { - const component = createComponent(jsxOrType, options); +async function innerUpdate(page: Page, componentRef: JsxComponent | ImportRef, options: ObjectComponentOptions = {}): Promise { + const component = createComponent(componentRef, options); wrapFunctions(component, page, boundCallbacksForMount); await page.evaluate(async ({ component }) => { @@ -97,13 +101,14 @@ async function innerUpdate(page: Page, jsxOrType: JsxComponent | string, options }; unwrapFunctions(component); + component = await window.__pwRegistry.resolveImports(component); const rootElement = document.getElementById('root')!; return await window.playwrightUpdate(rootElement, component); }, { component }); } -async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: MountOptions = {}): Promise { - const component = createComponent(jsxOrType, options); +async function innerMount(page: Page, componentRef: JsxComponent | ImportRef, options: ObjectComponentOptions & MountOptions = {}): Promise { + const component = createComponent(componentRef, options); wrapFunctions(component, page, boundCallbacksForMount); // WebKit does not wait for deferred scripts. @@ -130,7 +135,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: rootElement.id = 'root'; document.body.appendChild(rootElement); } - + component = await window.__pwRegistry.resolveImports(component); await window.playwrightMount(component, rootElement, hooksConfig); return '#root >> internal:control=component'; @@ -138,9 +143,14 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options: return selector; } -function createComponent(jsxOrType: JsxComponent | string, options: Omit = {}): Component { - if (typeof jsxOrType !== 'string') return jsxOrType; - return { __pw_component_marker: true, kind: 'object', type: jsxOrType, options }; +function createComponent(component: JsxComponent | ImportRef, options: ObjectComponentOptions = {}): Component { + if (component.__pw_type === 'jsx') + return component; + return { + __pw_type: 'object-component', + type: component, + ...options, + }; } function wrapFunctions(object: any, page: Page, callbacks: Function[]) { diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 21520f1b63220..63412496adb1a 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -20,9 +20,8 @@ import { types, declare, traverse } from 'playwright/lib/transform/babelBundle'; import { resolveImportSpecifierExtension } from 'playwright/lib/util'; const t: typeof T = types; -const fullNames = new Map(); let componentNames: Set; -let componentIdentifiers: Set; +let componentImports: Map; export default declare((api: BabelAPI) => { api.assertVersion(7); @@ -30,11 +29,41 @@ export default declare((api: BabelAPI) => { const result: PluginObj = { name: 'playwright-debug-transform', visitor: { - Program(path) { - fullNames.clear(); - const result = collectComponentUsages(path.node); - componentNames = result.names; - componentIdentifiers = result.identifiers; + Program: { + enter(path) { + const result = collectComponentUsages(path.node); + componentNames = result.names; + componentImports = new Map(); + }, + exit(path) { + let firstDeclaration: any; + let lastImportDeclaration: any; + path.get('body').forEach(p => { + if (p.isImportDeclaration()) + lastImportDeclaration = p; + else if (!firstDeclaration) + firstDeclaration = p; + }); + const insertionPath = lastImportDeclaration || firstDeclaration; + if (!insertionPath) + return; + for (const componentImport of [...componentImports.values()].reverse()) { + insertionPath.insertAfter( + t.variableDeclaration( + 'const', + [ + t.variableDeclarator( + t.identifier(componentImport.localName), + t.objectExpression([ + t.objectProperty(t.identifier('__pw_type'), t.stringLiteral('importRef')), + t.objectProperty(t.identifier('id'), t.stringLiteral(componentImport.id)), + ]), + ) + ] + ) + ); + } + } }, ImportDeclaration(p) { @@ -44,14 +73,12 @@ export default declare((api: BabelAPI) => { let components = 0; for (const specifier of importNode.specifiers) { - const specifierName = specifier.local.name; - const componentName = componentNames.has(specifierName) ? specifierName : [...componentNames].find(c => c.startsWith(specifierName + '.')); - if (!componentName) - continue; if (t.isImportNamespaceSpecifier(specifier)) continue; - const { fullName } = componentInfo(specifier, importNode.source.value, this.filename!, componentName); - fullNames.set(componentName, fullName); + const info = importInfo(importNode, specifier, this.filename!); + if (!componentNames.has(info.localName)) + continue; + componentImports.set(info.localName, info); ++components; } @@ -62,76 +89,20 @@ export default declare((api: BabelAPI) => { } }, - Identifier(p) { - if (componentIdentifiers.has(p.node)) { - const componentName = fullNames.get(p.node.name) || p.node.name; - p.replaceWith(t.stringLiteral(componentName)); - } - }, - - JSXElement(path) { - const jsxElement = path.node; - const jsxName = jsxElement.openingElement.name; - let nameOrExpression: string = ''; - if (t.isJSXIdentifier(jsxName)) - nameOrExpression = jsxName.name; - else if (t.isJSXMemberExpression(jsxName) && t.isJSXIdentifier(jsxName.object) && t.isJSXIdentifier(jsxName.property)) - nameOrExpression = jsxName.object.name + '.' + jsxName.property.name; - if (!nameOrExpression) + MemberExpression(path) { + if (!t.isIdentifier(path.node.object)) return; - const componentName = fullNames.get(nameOrExpression) || nameOrExpression; - - const props: (T.ObjectProperty | T.SpreadElement)[] = []; - - for (const jsxAttribute of jsxElement.openingElement.attributes) { - if (t.isJSXAttribute(jsxAttribute)) { - let namespace: T.JSXIdentifier | undefined; - let name: T.JSXIdentifier | undefined; - if (t.isJSXNamespacedName(jsxAttribute.name)) { - namespace = jsxAttribute.name.namespace; - name = jsxAttribute.name.name; - } else if (t.isJSXIdentifier(jsxAttribute.name)) { - name = jsxAttribute.name; - } - if (!name) - continue; - const attrName = (namespace ? namespace.name + ':' : '') + name.name; - if (t.isStringLiteral(jsxAttribute.value)) - props.push(t.objectProperty(t.stringLiteral(attrName), jsxAttribute.value)); - else if (t.isJSXExpressionContainer(jsxAttribute.value) && t.isExpression(jsxAttribute.value.expression)) - props.push(t.objectProperty(t.stringLiteral(attrName), jsxAttribute.value.expression)); - else if (jsxAttribute.value === null) - props.push(t.objectProperty(t.stringLiteral(attrName), t.booleanLiteral(true))); - else - props.push(t.objectProperty(t.stringLiteral(attrName), t.nullLiteral())); - } else if (t.isJSXSpreadAttribute(jsxAttribute)) { - props.push(t.spreadElement(jsxAttribute.argument)); - } - } - - const children: (T.Expression | T.SpreadElement)[] = []; - for (const child of jsxElement.children) { - if (t.isJSXText(child)) - children.push(t.stringLiteral(child.value)); - else if (t.isJSXElement(child)) - children.push(child); - else if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) - children.push(child.expression); - else if (t.isJSXSpreadChild(child)) - children.push(t.spreadElement(child.expression)); - } - - const component: T.ObjectProperty[] = [ - t.objectProperty(t.identifier('__pw_component_marker'), t.booleanLiteral(true)), - t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')), - t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)), - t.objectProperty(t.identifier('props'), t.objectExpression(props)), - ]; - if (children.length) - component.push(t.objectProperty(t.identifier('children'), t.arrayExpression(children))); - - path.replaceWith(t.objectExpression(component)); - } + if (!componentImports.has(path.node.object.name)) + return; + if (!t.isIdentifier(path.node.property)) + return; + path.replaceWith( + t.objectExpression([ + t.spreadElement(t.identifier(path.node.object.name)), + t.objectProperty(t.identifier('property'), t.stringLiteral(path.node.property.name)), + ]) + ); + }, } }; return result; @@ -140,7 +111,6 @@ export default declare((api: BabelAPI) => { export function collectComponentUsages(node: T.Node) { const importedLocalNames = new Set(); const names = new Set(); - const identifiers = new Set(); traverse(node, { enter: p => { @@ -162,7 +132,7 @@ export function collectComponentUsages(node: T.Node) { if (t.isJSXIdentifier(p.node.openingElement.name)) names.add(p.node.openingElement.name.name); if (t.isJSXMemberExpression(p.node.openingElement.name) && t.isJSXIdentifier(p.node.openingElement.name.object) && t.isJSXIdentifier(p.node.openingElement.name.property)) - names.add(p.node.openingElement.name.object.name + '.' + p.node.openingElement.name.property.name); + names.add(p.node.openingElement.name.object.name); } // Treat mount(identifier, ...) as component usage if it is in the importedLocalNames list. @@ -173,45 +143,46 @@ export function collectComponentUsages(node: T.Node) { return; names.add(arg.name); - identifiers.add(arg); } } }); - return { names, identifiers }; + return { names }; } -export type ComponentInfo = { - fullName: string; - importPath: string; +export type ImportInfo = { + id: string; isModuleOrAlias: boolean; - importedName?: string; - importedNameProperty?: string; - deps: string[]; + importPath: string; + localName: string; + remoteName: string | undefined; }; -export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, importSource: string, filename: string, componentName: string): ComponentInfo { +export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, filename: string): ImportInfo { + const importSource = importNode.source.value; const isModuleOrAlias = !importSource.startsWith('.'); const unresolvedImportPath = path.resolve(path.dirname(filename), importSource); // Support following notations for Button.tsx: // - import { Button } from './Button.js' - via resolveImportSpecifierExtension // - import { Button } from './Button' - via require.resolve const importPath = isModuleOrAlias ? importSource : resolveImportSpecifierExtension(unresolvedImportPath) || require.resolve(unresolvedImportPath); - const prefix = importPath.replace(/[^\w_\d]/g, '_'); - const pathInfo = { importPath, isModuleOrAlias }; - - const specifierName = specifier.local.name; - let fullNameSuffix = ''; - let importedNameProperty = ''; - if (componentName !== specifierName) { - const suffix = componentName.substring(specifierName.length + 1); - fullNameSuffix = '_' + suffix; - importedNameProperty = '.' + suffix; - } + const idPrefix = importPath.replace(/[^\w_\d]/g, '_'); + + const result: ImportInfo = { + id: idPrefix, + importPath, + isModuleOrAlias, + localName: specifier.local.name, + remoteName: undefined, + }; - if (t.isImportDefaultSpecifier(specifier)) - return { fullName: prefix + fullNameSuffix, importedNameProperty, deps: [], ...pathInfo }; + if (t.isImportDefaultSpecifier(specifier)) { + } else if (t.isIdentifier(specifier.imported)) { + result.remoteName = specifier.imported.name; + } else { + result.remoteName = specifier.imported.value; + } - if (t.isIdentifier(specifier.imported)) - return { fullName: prefix + '_' + specifier.imported.name + fullNameSuffix, importedName: specifier.imported.name, importedNameProperty, deps: [], ...pathInfo }; - return { fullName: prefix + '_' + specifier.imported.value + fullNameSuffix, importedName: specifier.imported.value, importedNameProperty, deps: [], ...pathInfo }; + if (result.remoteName) + result.id += '_' + result.remoteName; + return result; } diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index abf4c9dc22066..82022d18604dc 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -19,7 +19,6 @@ import type { PlaywrightTestConfig as BasePlaywrightTestConfig, FullConfig } fro import type { InlineConfig, Plugin, ResolveFn, ResolvedConfig, UserConfig } from 'vite'; import type { TestRunnerPlugin } from '../../playwright/src/plugins'; -import type { ComponentInfo } from './tsxTransform'; import type { AddressInfo } from 'net'; import type { PluginContext } from 'rollup'; import { debug } from 'playwright-core/lib/utilsBundle'; @@ -31,14 +30,23 @@ import { stoppable } from 'playwright/lib/utilsBundle'; import { assert, calculateSha1 } from 'playwright-core/lib/utils'; import { getPlaywrightVersion } from 'playwright-core/lib/utils'; import { setExternalDependencies } from 'playwright/lib/transform/compilationCache'; -import { collectComponentUsages, componentInfo } from './tsxTransform'; +import { collectComponentUsages, importInfo } from './tsxTransform'; import { version as viteVersion, build, preview, mergeConfig } from 'vite'; +import type { ImportInfo } from './tsxTransform'; const log = debug('pw:vite'); let stoppableServer: any; const playwrightVersion = getPlaywrightVersion(); +type ComponentInfo = { + id: string; + importPath: string; + isModuleOrAlias: boolean; + remoteName: string | undefined; + deps: string[]; +}; + type CtConfig = BasePlaywrightTestConfig['use'] & { ctPort?: number; ctTemplateDir?: string; @@ -107,7 +115,13 @@ export function createPlugin( let buildExists = false; let buildInfo: BuildInfo; - const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8'); + const importRegistryFile = await fs.promises.readFile(path.resolve(__dirname, 'importRegistry.js'), 'utf-8'); + assert(importRegistryFile.includes(importRegistryPrefix)); + assert(importRegistryFile.includes(importRegistrySuffix)); + const importRegistrySource = importRegistryFile.replace(importRegistryPrefix, '').replace(importRegistrySuffix, '') + ` + window.__pwRegistry = new ImportRegistry(); + `; + const registerSource = importRegistrySource + await fs.promises.readFile(registerSourceFile, 'utf-8'); const registerSourceHash = calculateSha1(registerSource); try { @@ -269,11 +283,19 @@ async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegist for (const testFile of testFiles) { const timestamp = (await fs.promises.stat(testFile)).mtimeMs; if (buildInfo.tests[testFile]?.timestamp !== timestamp) { - const components = await parseTestFile(testFile); + const componentImports = await parseTestFile(testFile); log('changed test:', testFile); - for (const component of components) - componentRegistry.set(component.fullName, component); - buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) }; + for (const componentImport of componentImports) { + const ci: ComponentInfo = { + id: componentImport.id, + isModuleOrAlias: componentImport.isModuleOrAlias, + importPath: componentImport.importPath, + remoteName: componentImport.remoteName, + deps: [], + }; + componentRegistry.set(componentImport.id, { ...ci, deps: [] }); + } + buildInfo.tests[testFile] = { timestamp, components: componentImports.map(c => c.id) }; hasNewTests = true; } } @@ -283,7 +305,7 @@ async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegist async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise { const newComponents = [...componentRegistry.keys()]; - const oldComponents = new Map(buildInfo.components.map(c => [c.fullName, c])); + const oldComponents = new Map(buildInfo.components.map(c => [c.id, c])); let hasNewComponents = false; for (const c of newComponents) { @@ -293,17 +315,17 @@ async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: Compo } } for (const c of oldComponents.values()) - componentRegistry.set(c.fullName, c); + componentRegistry.set(c.id, c); return hasNewComponents; } -async function parseTestFile(testFile: string): Promise { +async function parseTestFile(testFile: string): Promise { const text = await fs.promises.readFile(testFile, 'utf-8'); const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' }); const componentUsages = collectComponentUsages(ast); const componentNames = componentUsages.names; - const result: ComponentInfo[] = []; + const result: ImportInfo[] = []; traverse(ast, { enter: p => { @@ -313,13 +335,12 @@ async function parseTestFile(testFile: string): Promise { return; for (const specifier of importNode.specifiers) { - const specifierName = specifier.local.name; - const componentName = componentNames.has(specifierName) ? specifierName : [...componentNames].find(c => c.startsWith(specifierName + '.')); - if (!componentName) - continue; if (t.isImportNamespaceSpecifier(specifier)) continue; - result.push(componentInfo(specifier, importNode.source.value, testFile, componentName)); + const info = importInfo(importNode, specifier, testFile); + if (!componentNames.has(info.localName)) + continue; + result.push(info); } } } @@ -370,13 +391,10 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil for (const [alias, value] of componentRegistry) { const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/'); - if (value.importedName) - lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.${value.importedName + (value.importedNameProperty || '')});`); - else - lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.default${value.importedNameProperty || ''});`); + lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.${value.remoteName || 'default'});`); } - lines.push(`pwRegister({ ${[...componentRegistry.keys()].join(',\n ')} });`); + lines.push(`__pwRegistry.initialize({ ${[...componentRegistry.keys()].join(',\n ')} });`); return { code: lines.join('\n'), map: { mappings: '' } @@ -418,3 +436,13 @@ function hasJSComponents(components: ComponentInfo[]): boolean { } return false; } + + +const importRegistryPrefix = `"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ImportRegistry = void 0;`; + +const importRegistrySuffix = `exports.ImportRegistry = ImportRegistry;`; diff --git a/packages/playwright-ct-core/types/component.d.ts b/packages/playwright-ct-core/types/component.d.ts index 03949a996c137..9de92f9fde2f1 100644 --- a/packages/playwright-ct-core/types/component.d.ts +++ b/packages/playwright-ct-core/types/component.d.ts @@ -14,33 +14,38 @@ * limitations under the License. */ +import type { ImportRegistry } from '../src/importRegistry'; + type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonArray; type JsonArray = JsonValue[]; export type JsonObject = { [Key in string]?: JsonValue }; -// JsxComponentChild can be anything, consider cases like: <>{1}, <>{null} -export type JsxComponentChild = JsxComponent | string | number | boolean | null; +export type ImportRef = { + __pw_type: 'importRef', + id: string, + property?: string, +}; + export type JsxComponent = { - __pw_component_marker: true, - kind: 'jsx', - type: string, + __pw_type: 'jsx', + type: any, props: Record, - children?: JsxComponentChild[], }; export type MountOptions = { - props?: Record, - slots?: Record, - on?: Record, hooksConfig?: any, }; -export type ObjectComponent = { - __pw_component_marker: true, - kind: 'object', - type: string, - options?: MountOptions +export type ObjectComponentOptions = { + props?: Record; + slots?: Record; + on?: Record; +}; + +export type ObjectComponent = ObjectComponentOptions & { + __pw_type: 'object-component', + type: any, }; export type Component = JsxComponent | ObjectComponent; @@ -56,5 +61,6 @@ declare global { __pw_hooks_after_mount?: (( params: { hooksConfig?: HooksConfig; [key: string]: any } ) => Promise)[]; + __pwRegistry: ImportRegistry; } } diff --git a/packages/playwright-ct-react/index.d.ts b/packages/playwright-ct-react/index.d.ts index 807b929b9b592..7cfcf04ba0750 100644 --- a/packages/playwright-ct-react/index.d.ts +++ b/packages/playwright-ct-react/index.d.ts @@ -22,7 +22,7 @@ export interface MountOptions { hooksConfig?: HooksConfig; } -interface MountResult extends Locator { +export interface MountResult extends Locator { unmount(): Promise; update(component: JSX.Element): Promise; } diff --git a/packages/playwright-ct-react/registerSource.mjs b/packages/playwright-ct-react/registerSource.mjs index 713b612fa2dad..efdb9d56ca513 100644 --- a/packages/playwright-ct-react/registerSource.mjs +++ b/packages/playwright-ct-react/registerSource.mjs @@ -17,130 +17,48 @@ // @ts-check // This file is injected into the registry as text, no dependencies are allowed. -import * as __pwReact from 'react'; +import __pwReact from 'react'; import { createRoot as __pwCreateRoot } from 'react-dom/client'; - -/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */ /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ -/** @typedef {import('react').FunctionComponent} FrameworkComponent */ -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); /** @type {Map} */ const __pwRootRegistry = new Map(); -/** - * @param {Record Promise>} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} - /** * @param {any} component * @returns {component is JsxComponent} */ -function isComponent(component) { - return component.__pw_component_marker === true && component.kind === 'jsx'; +function isJsxComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } /** - * @param {JsxComponent | JsxComponentChild} component + * @param {any} value */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}`)) { - componentFactory = value; - break; - } - } +function __pwRender(value) { + if (value === null || typeof value !== 'object') + return value; + if (isJsxComponent(value)) { + const component = value; + const props = component.props ? __pwRender(component.props) : {}; + return __pwReact.createElement(/** @type { any } */ (component.type), { ...props, children: undefined }, props.children); } - - if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); - - if (component.children?.length) - await Promise.all(component.children.map(child => __pwResolveComponent(child))); - - if (component.props) - await __resolveProps(component.props); -} - -/** - * @param {Record} props - */ -async function __resolveProps(props) { - for (const prop of Object.values(props)) { - if (Array.isArray(prop)) - await Promise.all(prop.map(child => __pwResolveComponent(child))); - else if (isComponent(prop)) - await __pwResolveComponent(prop); - else if (typeof prop === 'object' && prop !== null) - await __resolveProps(prop); + if (Array.isArray(value)) { + const result = []; + for (const item of value) + result.push(__pwRender(item)); + return result; } -} - -/** - * @param {JsxComponentChild} child - */ -function __renderChild(child) { - if (Array.isArray(child)) - return child.map(grandChild => __renderChild(grandChild)); - if (isComponent(child)) - return __pwRender(child); - return child; -} - -/** - * @param {Record} props - */ -function __renderProps(props) { - const newProps = {}; - for (const [key, prop] of Object.entries(props)) { - if (Array.isArray(prop)) - newProps[key] = prop.map(child => __renderChild(child)); - else if (isComponent(prop)) - newProps[key] = __renderChild(prop); - else if (typeof prop === 'object' && prop !== null) - newProps[key] = __renderProps(prop); - else - newProps[key] = prop; - } - return newProps; -} - -/** - * @param {JsxComponent} component - */ -function __pwRender(component) { - const componentFunc = __pwRegistry.get(component.type); - const props = __renderProps(component.props || {}); - const children = component.children?.map(child => __renderChild(child)).filter(child => { - if (typeof child === 'string') - return !!child.trim(); - return true; - }); - const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children; - return __pwReact.createElement(componentFunc || component.type, props, reactChildren); + const result = {}; + for (const [key, prop] of Object.entries(value)) + result[key] = __pwRender(prop); + return result; } window.playwrightMount = async (component, rootElement, hooksConfig) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); - await __pwResolveComponent(component); let App = () => __pwRender(component); for (const hook of window.__pw_hooks_before_mount || []) { const wrapper = await hook({ App, hooksConfig }); @@ -171,10 +89,9 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (rootElement, component) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); - await __pwResolveComponent(component); const root = __pwRootRegistry.get(rootElement); if (root === undefined) throw new Error('Component was not mounted'); diff --git a/packages/playwright-ct-react17/registerSource.mjs b/packages/playwright-ct-react17/registerSource.mjs index d0d6f2c37faa3..f03d7b8d161c0 100644 --- a/packages/playwright-ct-react17/registerSource.mjs +++ b/packages/playwright-ct-react17/registerSource.mjs @@ -17,93 +17,45 @@ // @ts-check // This file is injected into the registry as text, no dependencies are allowed. -// Don't clash with the user land. import __pwReact from 'react'; import __pwReactDOM from 'react-dom'; - -/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */ /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ -/** @typedef {import('react').FunctionComponent} FrameworkComponent */ - -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); - -/** - * @param {{[key: string]: () => Promise}} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} /** * @param {any} component * @returns {component is JsxComponent} */ -function isComponent(component) { - return !(typeof component !== 'object' || Array.isArray(component)); +function isJsxComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } /** - * @param {JsxComponent | JsxComponentChild} component + * @param {any} value */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}`)) { - componentFactory = value; - break; - } - } +function __pwRender(value) { + if (value === null || typeof value !== 'object') + return value; + if (isJsxComponent(value)) { + const component = value; + const props = component.props ? __pwRender(component.props) : {}; + return __pwReact.createElement(/** @type { any } */ (component.type), { ...props, children: undefined }, props.children); } - - if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); - - if (component.children?.length) - await Promise.all(component.children.map(child => __pwResolveComponent(child))); -} - -/** - * @param {JsxComponentChild} child - */ -function __renderChild(child) { - if (Array.isArray(child)) - return child.map(grandChild => __renderChild(grandChild)); - if (isComponent(child)) - return __pwRender(child); - return child; -} - -/** - * @param {JsxComponent} component - */ -function __pwRender(component) { - const componentFunc = __pwRegistry.get(component.type); - const children = component.children?.map(child => __renderChild(child)).filter(child => { - if (typeof child === 'string') - return !!child.trim(); - return true; - }); - const reactChildren = Array.isArray(children) && children.length === 1 ? children[0] : children; - return __pwReact.createElement(componentFunc || component.type, component.props, reactChildren); + if (Array.isArray(value)) { + const result = []; + for (const item of value) + result.push(__pwRender(item)); + return result; + } + const result = {}; + for (const [key, prop] of Object.entries(value)) + result[key] = __pwRender(prop); + return result; } window.playwrightMount = async (component, rootElement, hooksConfig) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); - await __pwResolveComponent(component); let App = () => __pwRender(component); for (const hook of window.__pw_hooks_before_mount || []) { const wrapper = await hook({ App, hooksConfig }); @@ -123,9 +75,8 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (rootElement, component) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); - await __pwResolveComponent(component); __pwReactDOM.render(__pwRender(component), rootElement); }; diff --git a/packages/playwright-ct-solid/registerSource.mjs b/packages/playwright-ct-solid/registerSource.mjs index 1704a4ba9a23d..a76792c5135ba 100644 --- a/packages/playwright-ct-solid/registerSource.mjs +++ b/packages/playwright-ct-solid/registerSource.mjs @@ -20,94 +20,62 @@ import { render as __pwSolidRender, createComponent as __pwSolidCreateComponent } from 'solid-js/web'; import __pwH from 'solid-js/h'; -/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */ /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ /** @typedef {() => import('solid-js').JSX.Element} FrameworkComponent */ -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); - -/** - * @param {{[key: string]: () => Promise}} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} - /** * @param {any} component * @returns {component is JsxComponent} */ -function isComponent(component) { - return !(typeof component !== 'object' || Array.isArray(component)); +function isJsxComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } /** - * @param {JsxComponent | JsxComponentChild} component - */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}`)) { - componentFactory = value; - break; - } - } - } - - if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); - - if (component.children?.length) - await Promise.all(component.children.map(child => __pwResolveComponent(child))); -} - -/** - * @param {JsxComponentChild} child + * @param {any} child */ function __pwCreateChild(child) { if (Array.isArray(child)) return child.map(grandChild => __pwCreateChild(grandChild)); - if (isComponent(child)) + if (isJsxComponent(child)) return __pwCreateComponent(child); return child; } +/** + * @param {JsxComponent} component + * @returns {any[] | undefined} + */ +function __pwJsxChildArray(component) { + if (!component.props.children) + return; + if (Array.isArray(component.props.children)) + return component.props.children; + return [component.props.children]; +} + /** * @param {JsxComponent} component */ function __pwCreateComponent(component) { - const componentFunc = __pwRegistry.get(component.type); - const children = component.children?.map(child => __pwCreateChild(child)).filter(child => { + const children = __pwJsxChildArray(component)?.map(child => __pwCreateChild(child)).filter(child => { if (typeof child === 'string') return !!child.trim(); return true; }); - if (!componentFunc) + if (typeof component.type === 'string') return __pwH(component.type, component.props, children); - return __pwSolidCreateComponent(componentFunc, { ...component.props, children }); + return __pwSolidCreateComponent(component.type, { ...component.props, children }); } const __pwUnmountKey = Symbol('unmountKey'); window.playwrightMount = async (component, rootElement, hooksConfig) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); - await __pwResolveComponent(component); let App = () => __pwCreateComponent(component); for (const hook of window.__pw_hooks_before_mount || []) { const wrapper = await hook({ App, hooksConfig }); @@ -131,7 +99,7 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (rootElement, component) => { - if (component.kind !== 'jsx') + if (!isJsxComponent(component)) throw new Error('Object mount notation is not supported'); window.playwrightUnmount(rootElement); diff --git a/packages/playwright-ct-svelte/registerSource.mjs b/packages/playwright-ct-svelte/registerSource.mjs index 8cdd3e43ee54d..4552a4ba0aa46 100644 --- a/packages/playwright-ct-svelte/registerSource.mjs +++ b/packages/playwright-ct-svelte/registerSource.mjs @@ -25,50 +25,12 @@ import { detach as __pwDetach, insert as __pwInsert, noop as __pwNoop } from 'sv /** @typedef {any} FrameworkComponent */ /** @typedef {import('svelte').SvelteComponent} SvelteComponent */ -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); - -/** - * @param {{[key: string]: () => Promise}} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} - /** * @param {any} component * @returns {component is ObjectComponent} */ -function isComponent(component) { - return !(typeof component !== 'object' || Array.isArray(component)); -} - -/** - * @param {ObjectComponent} component - */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}_svelte`)) { - componentFactory = value; - break; - } - } - } - - if (!componentFactory) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); +function isObjectComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'object-component'; } /** @@ -105,19 +67,19 @@ function __pwCreateSlots(slots) { const __pwSvelteComponentKey = Symbol('svelteComponent'); window.playwrightMount = async (component, rootElement, hooksConfig) => { - if (component.kind !== 'object') + if (!isObjectComponent(component)) throw new Error('JSX mount notation is not supported'); - await __pwResolveComponent(component); - const componentCtor = __pwRegistry.get(component.type); + const objectComponent = component; + const componentCtor = component.type; class App extends componentCtor { constructor(options = {}) { super({ target: rootElement, props: { - ...component.options?.props, - $$slots: __pwCreateSlots(component.options?.slots), + ...objectComponent.props, + $$slots: __pwCreateSlots(objectComponent.slots), $$scope: {}, }, ...options @@ -134,7 +96,7 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { rootElement[__pwSvelteComponentKey] = svelteComponent; - for (const [key, listener] of Object.entries(component.options?.on || {})) + for (const [key, listener] of Object.entries(objectComponent.on || {})) svelteComponent.$on(key, event => listener(event.detail)); for (const hook of window.__pw_hooks_after_mount || []) @@ -149,17 +111,16 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (rootElement, component) => { - if (component.kind !== 'object') + if (!isObjectComponent(component)) throw new Error('JSX mount notation is not supported'); - await __pwResolveComponent(component); const svelteComponent = /** @type {SvelteComponent} */ (rootElement[__pwSvelteComponentKey]); if (!svelteComponent) throw new Error('Component was not mounted'); - for (const [key, listener] of Object.entries(component.options?.on || {})) + for (const [key, listener] of Object.entries(component.on || {})) svelteComponent.$on(key, event => listener(event.detail)); - if (component.options?.props) - svelteComponent.$set(component.options.props); + if (component.props) + svelteComponent.$set(component.props); }; diff --git a/packages/playwright-ct-vue/registerSource.mjs b/packages/playwright-ct-vue/registerSource.mjs index 5935322ad0eb1..5256a241a4927 100644 --- a/packages/playwright-ct-vue/registerSource.mjs +++ b/packages/playwright-ct-vue/registerSource.mjs @@ -22,69 +22,35 @@ import { compile as __pwCompile } from '@vue/compiler-dom'; import * as __pwVue from 'vue'; /** @typedef {import('../playwright-ct-core/types/component').Component} Component */ -/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */ /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ /** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */ /** @typedef {import('vue').Component} FrameworkComponent */ -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); - -/** - * @param {{[key: string]: () => Promise}} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} +const __pwAllListeners = new Map(); /** * @param {any} component - * @returns {component is Component} + * @returns {component is ObjectComponent} */ -function isComponent(component) { - return !(typeof component !== 'object' || Array.isArray(component)); +function isObjectComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'object-component'; } /** - * @param {Component | JsxComponentChild} component + * @param {any} component + * @returns {component is JsxComponent} */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}_vue`)) { - componentFactory = value; - break; - } - } - } - - if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); - - if ('children' in component && component.children?.length) - await Promise.all(component.children.map(child => __pwResolveComponent(child))); +function isJsxComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } -const __pwAllListeners = new Map(); - /** - * @param {JsxComponentChild} child + * @param {any} child */ function __pwCreateChild(child) { if (Array.isArray(child)) return child.map(grandChild => __pwCreateChild(grandChild)); - if (isComponent(child)) + if (isJsxComponent(child) || isObjectComponent(child)) return __pwCreateWrapper(child); return child; } @@ -132,14 +98,23 @@ function __pwSlotToFunction(slot) { throw Error(`Invalid slot received.`); } +/** + * @param {JsxComponent} component + * @returns {any[] | undefined} + */ +function __pwJsxChildArray(component) { + if (!component.props.children) + return; + if (Array.isArray(component.props.children)) + return component.props.children; + return [component.props.children]; +} + /** * @param {Component} component */ function __pwCreateComponent(component) { - let componentFunc = __pwRegistry.get(component.type); - componentFunc = componentFunc || component.type; - - const isVueComponent = componentFunc !== component.type; + const isVueComponent = typeof component.type !== 'string'; /** * @type {(import('vue').VNode | string)[]} @@ -151,12 +126,12 @@ function __pwCreateComponent(component) { /** @type {{[key: string]: any}} */ let props = {}; - if (component.kind === 'jsx') { - for (const child of component.children || []) { - if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') { + if (component.__pw_type === 'jsx') { + for (const child of __pwJsxChildArray(component) || []) { + if (isJsxComponent(child) && child.type === 'template') { const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:')); const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default'; - slots[slot] = child.children?.map(__pwCreateChild); + slots[slot] = __pwJsxChildArray(child)?.map(__pwCreateChild); } else { children.push(__pwCreateChild(child)); } @@ -175,16 +150,16 @@ function __pwCreateComponent(component) { } } - if (component.kind === 'object') { + if (component.__pw_type === 'object-component') { // Vue test util syntax. - for (const [key, value] of Object.entries(component.options?.slots || {})) { + for (const [key, value] of Object.entries(component.slots || {})) { if (key === 'default') children.push(__pwSlotToFunction(value)); else slots[key] = __pwSlotToFunction(value); } - props = component.options?.props || {}; - for (const [key, value] of Object.entries(component.options?.on || {})) + props = component.props || {}; + for (const [key, value] of Object.entries(component.on || {})) listeners[key] = value; } @@ -197,7 +172,7 @@ function __pwCreateComponent(component) { lastArg = children; } - return { Component: componentFunc, props, slots: lastArg, listeners }; + return { Component: component.type, props, slots: lastArg, listeners }; } function __pwWrapFunctions(slots) { @@ -248,7 +223,6 @@ const __pwAppKey = Symbol('appKey'); const __pwWrapperKey = Symbol('wrapperKey'); window.playwrightMount = async (component, rootElement, hooksConfig) => { - await __pwResolveComponent(component); const app = __pwCreateApp({ render: () => { const wrapper = __pwCreateWrapper(component); @@ -275,7 +249,6 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (rootElement, component) => { - await __pwResolveComponent(component); const wrapper = rootElement[__pwWrapperKey]; if (!wrapper) throw new Error('Component was not mounted'); diff --git a/packages/playwright-ct-vue2/registerSource.mjs b/packages/playwright-ct-vue2/registerSource.mjs index 043c680229ec8..b0a7ae71dc52b 100644 --- a/packages/playwright-ct-vue2/registerSource.mjs +++ b/packages/playwright-ct-vue2/registerSource.mjs @@ -21,67 +21,33 @@ import __pwVue, { h as __pwH } from 'vue'; /** @typedef {import('../playwright-ct-core/types/component').Component} Component */ -/** @typedef {import('../playwright-ct-core/types/component').JsxComponentChild} JsxComponentChild */ /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ /** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */ /** @typedef {import('vue').Component} FrameworkComponent */ -/** @type {Map Promise>} */ -const __pwLoaderRegistry = new Map(); -/** @type {Map} */ -const __pwRegistry = new Map(); - -/** - * @param {{[key: string]: () => Promise}} components - */ -export function pwRegister(components) { - for (const [name, value] of Object.entries(components)) - __pwLoaderRegistry.set(name, value); -} - /** * @param {any} component - * @returns {component is Component} + * @returns {component is ObjectComponent} */ -function isComponent(component) { - return !(typeof component !== 'object' || Array.isArray(component)); +function isObjectComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'object-component'; } /** - * @param {Component | JsxComponentChild} component + * @param {any} component + * @returns {component is JsxComponent} */ -async function __pwResolveComponent(component) { - if (!isComponent(component)) - return; - - let componentFactory = __pwLoaderRegistry.get(component.type); - if (!componentFactory) { - // Lookup by shorthand. - for (const [name, value] of __pwLoaderRegistry) { - if (component.type.endsWith(`_${name}_vue`)) { - componentFactory = value; - break; - } - } - } - - if (!componentFactory && component.type[0].toUpperCase() === component.type[0]) - throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`); - - if (componentFactory) - __pwRegistry.set(component.type, await componentFactory()); - - if ('children' in component && component.children?.length) - await Promise.all(component.children.map(child => __pwResolveComponent(child))); +function isJsxComponent(component) { + return typeof component === 'object' && component && component.__pw_type === 'jsx'; } /** - * @param {Component | JsxComponentChild} child + * @param {any} child */ function __pwCreateChild(child) { if (Array.isArray(child)) return child.map(grandChild => __pwCreateChild(grandChild)); - if (isComponent(child)) + if (isJsxComponent(child) || isObjectComponent(child)) return __pwCreateWrapper(child); return child; } @@ -94,18 +60,26 @@ function __pwCreateChild(child) { * @return {boolean} */ function __pwComponentHasKeyInProps(Component, key) { - if (Array.isArray(Component.props)) - return Component.props.includes(key); + return typeof Component.props === 'object' && Component.props && key in Component.props; +} - return Object.entries(Component.props).flat().includes(key); +/** + * @param {JsxComponent} component + * @returns {any[] | undefined} + */ +function __pwJsxChildArray(component) { + if (!component.props.children) + return; + if (Array.isArray(component.props.children)) + return component.props.children; + return [component.props.children]; } /** * @param {Component} component */ function __pwCreateComponent(component) { - const componentFunc = __pwRegistry.get(component.type) || component.type; - const isVueComponent = componentFunc !== component.type; + const isVueComponent = typeof component.type !== 'string'; /** * @type {(import('vue').VNode | string)[]} @@ -119,12 +93,12 @@ function __pwCreateComponent(component) { nodeData.scopedSlots = {}; nodeData.on = {}; - if (component.kind === 'jsx') { - for (const child of component.children || []) { - if (typeof child !== 'string' && child.type === 'template' && child.kind === 'jsx') { + if (component.__pw_type === 'jsx') { + for (const child of __pwJsxChildArray(component) || []) { + if (isJsxComponent(child) && child.type === 'template') { const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:')); const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default'; - nodeData.scopedSlots[slot] = () => child.children?.map(c => __pwCreateChild(c)); + nodeData.scopedSlots[slot] = () => __pwJsxChildArray(child)?.map(c => __pwCreateChild(c)); } else { children.push(__pwCreateChild(child)); } @@ -135,7 +109,7 @@ function __pwCreateComponent(component) { const event = key.substring('v-on:'.length); nodeData.on[event] = value; } else { - if (isVueComponent && __pwComponentHasKeyInProps(componentFunc, key)) + if (isVueComponent && __pwComponentHasKeyInProps(component.type, key)) nodeData.props[key] = value; else nodeData.attrs[key] = value; @@ -143,18 +117,17 @@ function __pwCreateComponent(component) { } } - if (component.kind === 'object') { + if (component.__pw_type === 'object-component') { // Vue test util syntax. - const options = component.options || {}; - for (const [key, value] of Object.entries(options.slots || {})) { + for (const [key, value] of Object.entries(component.slots || {})) { const list = (Array.isArray(value) ? value : [value]).map(v => __pwCreateChild(v)); if (key === 'default') children.push(...list); else nodeData.scopedSlots[key] = () => list; } - nodeData.props = options.props || {}; - for (const [key, value] of Object.entries(options.on || {})) + nodeData.props = component.props || {}; + for (const [key, value] of Object.entries(component.on || {})) nodeData.on[key] = value; } @@ -167,7 +140,7 @@ function __pwCreateComponent(component) { lastArg = children; } - return { Component: componentFunc, nodeData, slots: lastArg }; + return { Component: component.type, nodeData, slots: lastArg }; } /** @@ -184,7 +157,6 @@ const instanceKey = Symbol('instanceKey'); const wrapperKey = Symbol('wrapperKey'); window.playwrightMount = async (component, rootElement, hooksConfig) => { - await __pwResolveComponent(component); let options = {}; for (const hook of window.__pw_hooks_before_mount || []) options = await hook({ hooksConfig, Vue: __pwVue }); @@ -213,7 +185,6 @@ window.playwrightUnmount = async rootElement => { }; window.playwrightUpdate = async (element, options) => { - await __pwResolveComponent(options); const wrapper = /** @type {any} */(element)[wrapperKey]; if (!wrapper) throw new Error('Component was not mounted'); diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index b8dfa198b0ec9..98cb0785dc99b 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -66,6 +66,7 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins // Support JSX/TSX at all times, regardless of the file extension. plugins.push([require('@babel/plugin-transform-react-jsx'), { + throwIfNamespace: false, runtime: 'automatic', importSource: path.dirname(require.resolve('playwright')), }]); diff --git a/packages/playwright/jsx-runtime.js b/packages/playwright/jsx-runtime.js index 56bbfb1ed8606..b8900a05bb120 100644 --- a/packages/playwright/jsx-runtime.js +++ b/packages/playwright/jsx-runtime.js @@ -16,6 +16,7 @@ function jsx(type, props) { return { + __pw_type: 'jsx', type, props, }; @@ -23,6 +24,7 @@ function jsx(type, props) { function jsxs(type, props) { return { + __pw_type: 'jsx', type, props, }; diff --git a/tests/components/ct-react17/tsconfig.json b/tests/components/ct-react17/tsconfig.json index fef2ed01eef0c..48864aeb82bcd 100644 --- a/tests/components/ct-react17/tsconfig.json +++ b/tests/components/ct-react17/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2015", "lib": [ "dom", "dom.iterable", diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 8a782ae0fed9c..39f6b26174ae1 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -523,11 +523,13 @@ test('should load jsx with top-level component', async ({ runInlineTest }) => { const component =
Hello world
; test('succeeds', () => { expect(component).toEqual({ + __pw_type: 'jsx', type: 'div', props: { children: [ 'Hello ', { + __pw_type: 'jsx', type: 'span', props: { children: 'world' diff --git a/tests/playwright-test/playwright.ct-build.spec.ts b/tests/playwright-test/playwright.ct-build.spec.ts index fcf75566fdb17..26b430e87027e 100644 --- a/tests/playwright-test/playwright.ct-build.spec.ts +++ b/tests/playwright-test/playwright.ct-build.spec.ts @@ -135,9 +135,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }); expect(metainfo.components).toEqual([{ - fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), - importedName: 'Button', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_button_tsx_Button'), + remoteName: 'Button', importPath: expect.stringContaining('button.tsx'), isModuleOrAlias: false, deps: [ @@ -145,9 +144,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('jsx-runtime.js'), ] }, { - fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), - importedName: 'ClashingName', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), + remoteName: 'ClashingName', importPath: expect.stringContaining('clashingNames1.tsx'), isModuleOrAlias: false, deps: [ @@ -155,9 +153,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('jsx-runtime.js'), ] }, { - fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), - importedName: 'ClashingName', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), + remoteName: 'ClashingName', importPath: expect.stringContaining('clashingNames2.tsx'), isModuleOrAlias: false, deps: [ @@ -165,9 +162,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('jsx-runtime.js'), ] }, { - fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'), - importedName: 'Component1', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_components_tsx_Component1'), + remoteName: 'Component1', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, deps: [ @@ -175,9 +171,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('jsx-runtime.js'), ] }, { - fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'), - importedName: 'Component2', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_components_tsx_Component2'), + remoteName: 'Component2', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, deps: [ @@ -185,9 +180,8 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect.stringContaining('jsx-runtime.js'), ] }, { - fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'), + id: expect.stringContaining('playwright_test_src_defaultExport_tsx'), importPath: expect.stringContaining('defaultExport.tsx'), - importedNameProperty: '', isModuleOrAlias: false, deps: [ expect.stringContaining('defaultExport.tsx'), @@ -497,9 +491,8 @@ test('should retain deps when test changes', async ({ runInlineTest }, testInfo) const metainfo = JSON.parse(fs.readFileSync(testInfo.outputPath('playwright/.cache/metainfo.json'), 'utf-8')); expect(metainfo.components).toEqual([{ - fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), - importedName: 'Button', - importedNameProperty: '', + id: expect.stringContaining('playwright_test_src_button_tsx_Button'), + remoteName: 'Button', importPath: expect.stringContaining('button.tsx'), isModuleOrAlias: false, deps: [ diff --git a/tests/playwright-test/playwright.ct-react.spec.ts b/tests/playwright-test/playwright.ct-react.spec.ts index 52572e413249c..04ddcc7f59ba2 100644 --- a/tests/playwright-test/playwright.ct-react.spec.ts +++ b/tests/playwright-test/playwright.ct-react.spec.ts @@ -196,7 +196,7 @@ test('should work with stray JSX import', async ({ runInlineTest }) => { expect(result.passed).toBe(1); }); -test.fixme('should work with stray JS import', async ({ runInlineTest }) => { +test('should work with stray JS import', async ({ runInlineTest }) => { const result = await runInlineTest({ 'playwright.config.ts': playwrightConfig, 'playwright/index.html': ``, @@ -481,7 +481,7 @@ test('should normalize children', async ({ runInlineTest }) => { import { OneChild, OtherComponent } from './component'; test("can pass an HTML element to OneChild", async ({ mount }) => { - const component = await mount(

child

); + const component = await mount(

child

); await expect(component).toHaveText("child"); });