chore(ct): allow using component as a property (#27272)

This commit is contained in:
Pavel Feldman 2023-09-25 17:00:52 -07:00 committed by GitHub
parent 4e62468aee
commit aed86c98a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 85 additions and 19 deletions

View File

@ -42,19 +42,21 @@ export default declare((api: BabelAPI) => {
if (!t.isStringLiteral(importNode.source)) if (!t.isStringLiteral(importNode.source))
return; return;
let remove = false; let components = 0;
for (const specifier of importNode.specifiers) { 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; continue;
if (t.isImportNamespaceSpecifier(specifier)) if (t.isImportNamespaceSpecifier(specifier))
continue; continue;
const { fullName } = componentInfo(specifier, importNode.source.value, this.filename!); const { fullName } = componentInfo(specifier, importNode.source.value, this.filename!, componentName);
fullNames.set(specifier.local.name, fullName); fullNames.set(componentName, fullName);
remove = true; ++components;
} }
// If one of the imports was a component, consider them all component imports. // All the imports were components => delete.
if (remove) { if (components && components === importNode.specifiers.length) {
p.skip(); p.skip();
p.remove(); p.remove();
} }
@ -70,8 +72,14 @@ export default declare((api: BabelAPI) => {
JSXElement(path) { JSXElement(path) {
const jsxElement = path.node; const jsxElement = path.node;
const jsxName = jsxElement.openingElement.name; 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; return;
const componentName = fullNames.get(nameOrExpression) || nameOrExpression;
const props: (T.ObjectProperty | T.SpreadElement)[] = []; const props: (T.ObjectProperty | T.SpreadElement)[] = [];
@ -113,7 +121,6 @@ export default declare((api: BabelAPI) => {
children.push(t.spreadElement(child.expression)); children.push(t.spreadElement(child.expression));
} }
const componentName = fullNames.get(jsxName.name) || jsxName.name;
path.replaceWith(t.objectExpression([ path.replaceWith(t.objectExpression([
t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')), t.objectProperty(t.identifier('kind'), t.stringLiteral('jsx')),
t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)), t.objectProperty(t.identifier('type'), t.stringLiteral(componentName)),
@ -147,8 +154,12 @@ export function collectComponentUsages(node: T.Node) {
} }
// Treat JSX-everything as component usages. // Treat JSX-everything as component usages.
if (t.isJSXElement(p.node) && t.isJSXIdentifier(p.node.openingElement.name)) if (t.isJSXElement(p.node)) {
names.add(p.node.openingElement.name.name); 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. // 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') { 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; importPath: string;
isModuleOrAlias: boolean; isModuleOrAlias: boolean;
importedName?: string; importedName?: string;
importedNameProperty?: string;
deps: 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 isModuleOrAlias = !importSource.startsWith('.');
const unresolvedImportPath = path.resolve(path.dirname(filename), importSource); const unresolvedImportPath = path.resolve(path.dirname(filename), importSource);
// Support following notations for Button.tsx: // 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 prefix = importPath.replace(/[^\w_\d]/g, '_');
const pathInfo = { importPath, isModuleOrAlias }; 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)) if (t.isImportDefaultSpecifier(specifier))
return { fullName: prefix, deps: [], ...pathInfo }; return { fullName: prefix + fullNameSuffix, importedNameProperty, deps: [], ...pathInfo };
if (t.isIdentifier(specifier.imported)) if (t.isIdentifier(specifier.imported))
return { fullName: prefix + '_' + specifier.imported.name, importedName: specifier.imported.name, deps: [], ...pathInfo }; return { fullName: prefix + '_' + specifier.imported.name + fullNameSuffix, importedName: specifier.imported.name, importedNameProperty, deps: [], ...pathInfo };
return { fullName: prefix + '_' + specifier.imported.value, importedName: specifier.imported.value, deps: [], ...pathInfo }; return { fullName: prefix + '_' + specifier.imported.value + fullNameSuffix, importedName: specifier.imported.value, importedNameProperty, deps: [], ...pathInfo };
} }

View File

@ -300,6 +300,7 @@ async function parseTestFile(testFile: string): Promise<ComponentInfo[]> {
const text = await fs.promises.readFile(testFile, 'utf-8'); const text = await fs.promises.readFile(testFile, 'utf-8');
const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' }); const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' });
const componentUsages = collectComponentUsages(ast); const componentUsages = collectComponentUsages(ast);
const componentNames = componentUsages.names;
const result: ComponentInfo[] = []; const result: ComponentInfo[] = [];
traverse(ast, { traverse(ast, {
@ -310,11 +311,13 @@ async function parseTestFile(testFile: string): Promise<ComponentInfo[]> {
return; return;
for (const specifier of importNode.specifiers) { 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; continue;
if (t.isImportNamespaceSpecifier(specifier)) if (t.isImportNamespaceSpecifier(specifier))
continue; 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) { for (const [alias, value] of componentRegistry) {
const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/'); const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/');
if (value.importedName) 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 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 ')} });`); lines.push(`pwRegister({ ${[...componentRegistry.keys()].join(',\n ')} });`);

View File

@ -137,6 +137,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
expect(metainfo.components).toEqual([{ expect(metainfo.components).toEqual([{
fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'),
importedName: 'Button', importedName: 'Button',
importedNameProperty: '',
importPath: expect.stringContaining('button.tsx'), importPath: expect.stringContaining('button.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
@ -146,6 +147,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
}, { }, {
fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), fullName: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'),
importedName: 'ClashingName', importedName: 'ClashingName',
importedNameProperty: '',
importPath: expect.stringContaining('clashingNames1.tsx'), importPath: expect.stringContaining('clashingNames1.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
@ -155,6 +157,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
}, { }, {
fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), fullName: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'),
importedName: 'ClashingName', importedName: 'ClashingName',
importedNameProperty: '',
importPath: expect.stringContaining('clashingNames2.tsx'), importPath: expect.stringContaining('clashingNames2.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
@ -164,6 +167,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
}, { }, {
fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'), fullName: expect.stringContaining('playwright_test_src_components_tsx_Component1'),
importedName: 'Component1', importedName: 'Component1',
importedNameProperty: '',
importPath: expect.stringContaining('components.tsx'), importPath: expect.stringContaining('components.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
@ -173,6 +177,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
}, { }, {
fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'), fullName: expect.stringContaining('playwright_test_src_components_tsx_Component2'),
importedName: 'Component2', importedName: 'Component2',
importedNameProperty: '',
importPath: expect.stringContaining('components.tsx'), importPath: expect.stringContaining('components.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
@ -182,6 +187,7 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => {
}, { }, {
fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'), fullName: expect.stringContaining('playwright_test_src_defaultExport_tsx'),
importPath: expect.stringContaining('defaultExport.tsx'), importPath: expect.stringContaining('defaultExport.tsx'),
importedNameProperty: '',
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [
expect.stringContaining('defaultExport.tsx'), expect.stringContaining('defaultExport.tsx'),
@ -493,6 +499,7 @@ test('should retain deps when test changes', async ({ runInlineTest }, testInfo)
expect(metainfo.components).toEqual([{ expect(metainfo.components).toEqual([{
fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'), fullName: expect.stringContaining('playwright_test_src_button_tsx_Button'),
importedName: 'Button', importedName: 'Button',
importedNameProperty: '',
importPath: expect.stringContaining('button.tsx'), importPath: expect.stringContaining('button.tsx'),
isModuleOrAlias: false, isModuleOrAlias: false,
deps: [ deps: [

View File

@ -337,3 +337,38 @@ test('should bundle public folder', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1); 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': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': `
`,
'src/button1.tsx': `
const Button = () => <button>Button 1</button>;
export const components1 = { Button };
`,
'src/button2.tsx': `
const Button = () => <button>Button 2</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(<components1.Button />);
await expect(component).toHaveText('Button 1');
});
test('pass 2', async ({ mount }) => {
const component = await mount(<components2.Button />);
await expect(component).toHaveText('Button 2');
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
});