diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index f06df3c04d..9c54877a30 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -42,19 +42,21 @@ export default declare((api: BabelAPI) => { if (!t.isStringLiteral(importNode.source)) return; - let remove = false; + let components = 0; for (const specifier of importNode.specifiers) { - if (!componentNames.has(specifier.local.name)) + 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!); - fullNames.set(specifier.local.name, fullName); - remove = true; + const { fullName } = componentInfo(specifier, importNode.source.value, this.filename!, componentName); + fullNames.set(componentName, fullName); + ++components; } - // If one of the imports was a component, consider them all component imports. - if (remove) { + // All the imports were components => delete. + if (components && components === importNode.specifiers.length) { p.skip(); p.remove(); } @@ -70,8 +72,14 @@ export default declare((api: BabelAPI) => { JSXElement(path) { const jsxElement = path.node; const jsxName = jsxElement.openingElement.name; - if (!t.isJSXIdentifier(jsxName)) + 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) return; + const componentName = fullNames.get(nameOrExpression) || nameOrExpression; const props: (T.ObjectProperty | T.SpreadElement)[] = []; @@ -113,7 +121,6 @@ export default declare((api: BabelAPI) => { children.push(t.spreadElement(child.expression)); } - const componentName = fullNames.get(jsxName.name) || jsxName.name; path.replaceWith(t.objectExpression([ t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')), t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)), @@ -147,8 +154,12 @@ export function collectComponentUsages(node: T.Node) { } // Treat JSX-everything as component usages. - if (t.isJSXElement(p.node) && t.isJSXIdentifier(p.node.openingElement.name)) - names.add(p.node.openingElement.name.name); + if (t.isJSXElement(p.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); + } // Treat mount(identifier, ...) as component usage if it is in the importedLocalNames list. if (t.isAwaitExpression(p.node) && t.isCallExpression(p.node.argument) && t.isIdentifier(p.node.argument.callee) && p.node.argument.callee.name === 'mount') { @@ -170,10 +181,11 @@ export type ComponentInfo = { importPath: string; isModuleOrAlias: boolean; importedName?: string; + importedNameProperty?: string; deps: string[]; }; -export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, importSource: string, filename: string): ComponentInfo { +export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, importSource: string, filename: string, componentName: string): ComponentInfo { const isModuleOrAlias = !importSource.startsWith('.'); const unresolvedImportPath = path.resolve(path.dirname(filename), importSource); // Support following notations for Button.tsx: @@ -183,10 +195,19 @@ export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpec 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; + } + if (t.isImportDefaultSpecifier(specifier)) - return { fullName: prefix, deps: [], ...pathInfo }; + return { fullName: prefix + fullNameSuffix, importedNameProperty, deps: [], ...pathInfo }; if (t.isIdentifier(specifier.imported)) - return { fullName: prefix + '_' + specifier.imported.name, importedName: specifier.imported.name, deps: [], ...pathInfo }; - return { fullName: prefix + '_' + specifier.imported.value, importedName: specifier.imported.value, deps: [], ...pathInfo }; + 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 }; } diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index 9705f4bbf5..2e76559025 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -300,6 +300,7 @@ 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[] = []; traverse(ast, { @@ -310,11 +311,13 @@ async function parseTestFile(testFile: string): Promise { return; for (const specifier of importNode.specifiers) { - if (!componentUsages.names.has(specifier.local.name)) + 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)); + result.push(componentInfo(specifier, importNode.source.value, testFile, componentName)); } } } @@ -366,9 +369,9 @@ 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});`); + lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.${value.importedName + (value.importedNameProperty || '')});`); else - lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.default);`); + lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.default${value.importedNameProperty || ''});`); } lines.push(`pwRegister({ ${[...componentRegistry.keys()].join(',\n ')} });`); diff --git a/tests/playwright-test/playwright.ct-build.spec.ts b/tests/playwright-test/playwright.ct-build.spec.ts index 866fedcab6..fcf75566fd 100644 --- a/tests/playwright-test/playwright.ct-build.spec.ts +++ b/tests/playwright-test/playwright.ct-build.spec.ts @@ -137,6 +137,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { expect(metainfo.components).toEqual([{ fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), importedName: 'Button', + importedNameProperty: '', importPath: expect.stringContaining('button.tsx'), isModuleOrAlias: false, deps: [ @@ -146,6 +147,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }, { fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), importedName: 'ClashingName', + importedNameProperty: '', importPath: expect.stringContaining('clashingNames1.tsx'), isModuleOrAlias: false, deps: [ @@ -155,6 +157,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }, { fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), importedName: 'ClashingName', + importedNameProperty: '', importPath: expect.stringContaining('clashingNames2.tsx'), isModuleOrAlias: false, deps: [ @@ -164,6 +167,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }, { fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'), importedName: 'Component1', + importedNameProperty: '', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, deps: [ @@ -173,6 +177,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }, { fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'), importedName: 'Component2', + importedNameProperty: '', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, deps: [ @@ -182,6 +187,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { }, { fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'), importPath: expect.stringContaining('defaultExport.tsx'), + importedNameProperty: '', isModuleOrAlias: false, deps: [ expect.stringContaining('defaultExport.tsx'), @@ -493,6 +499,7 @@ test('should retain deps when test changes', async ({ runInlineTest }, testInfo) expect(metainfo.components).toEqual([{ fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), importedName: 'Button', + importedNameProperty: '', 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 fdfa6ecb49..6ce7774565 100644 --- a/tests/playwright-test/playwright.ct-react.spec.ts +++ b/tests/playwright-test/playwright.ct-react.spec.ts @@ -337,3 +337,38 @@ test('should bundle public folder', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should work with property expressions in JSX', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightConfig, + 'playwright/index.html': ``, + 'playwright/index.ts': ` + `, + 'src/button1.tsx': ` + const Button = () => ; + export const components1 = { Button }; + `, + 'src/button2.tsx': ` + const Button = () => ; + export default { Button }; + `, + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { components1 } from './button1'; + import components2 from './button2'; + + test('pass 1', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button 1'); + }); + + test('pass 2', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button 2'); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +});