diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 63412496adb1a..6ef051a6b4c63 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -18,10 +18,11 @@ import path from 'path'; import type { T, BabelAPI, PluginObj } from 'playwright/src/transform/babelBundle'; import { types, declare, traverse } from 'playwright/lib/transform/babelBundle'; import { resolveImportSpecifierExtension } from 'playwright/lib/util'; +import { setTransformData } from 'playwright/lib/transform/transform'; const t: typeof T = types; -let componentNames: Set; -let componentImports: Map; +let jsxComponentNames: Set; +let importInfos: Map; export default declare((api: BabelAPI) => { api.assertVersion(7); @@ -31,9 +32,8 @@ export default declare((api: BabelAPI) => { visitor: { Program: { enter(path) { - const result = collectComponentUsages(path.node); - componentNames = result.names; - componentImports = new Map(); + jsxComponentNames = collectJsxComponentUsages(path.node); + importInfos = new Map(); }, exit(path) { let firstDeclaration: any; @@ -47,13 +47,14 @@ export default declare((api: BabelAPI) => { const insertionPath = lastImportDeclaration || firstDeclaration; if (!insertionPath) return; - for (const componentImport of [...componentImports.values()].reverse()) { + + for (const [localName, componentImport] of [...importInfos.entries()].reverse()) { insertionPath.insertAfter( t.variableDeclaration( 'const', [ t.variableDeclarator( - t.identifier(componentImport.localName), + t.identifier(localName), t.objectExpression([ t.objectProperty(t.identifier('__pw_type'), t.stringLiteral('importRef')), t.objectProperty(t.identifier('id'), t.stringLiteral(componentImport.id)), @@ -63,6 +64,7 @@ export default declare((api: BabelAPI) => { ) ); } + setTransformData('playwright-ct-core', [...importInfos.values()]); } }, @@ -71,19 +73,35 @@ export default declare((api: BabelAPI) => { if (!t.isStringLiteral(importNode.source)) return; - let components = 0; + const ext = path.extname(importNode.source.value); + + // Convert all non-JS imports into refs. + if (!allJsExtensions.has(ext)) { + for (const specifier of importNode.specifiers) { + if (t.isImportNamespaceSpecifier(specifier)) + continue; + const { localName, info } = importInfo(importNode, specifier, this.filename!); + importInfos.set(localName, info); + } + p.skip(); + p.remove(); + return; + } + + // Convert JS imports that are used as components in JSX expressions into refs. + let importCount = 0; for (const specifier of importNode.specifiers) { if (t.isImportNamespaceSpecifier(specifier)) continue; - const info = importInfo(importNode, specifier, this.filename!); - if (!componentNames.has(info.localName)) - continue; - componentImports.set(info.localName, info); - ++components; + const { localName, info } = importInfo(importNode, specifier, this.filename!); + if (jsxComponentNames.has(localName)) { + importInfos.set(localName, info); + ++importCount; + } } - // All the imports were components => delete. - if (components && components === importNode.specifiers.length) { + // All the imports were from JSX => delete. + if (importCount && importCount === importNode.specifiers.length) { p.skip(); p.remove(); } @@ -92,7 +110,7 @@ export default declare((api: BabelAPI) => { MemberExpression(path) { if (!t.isIdentifier(path.node.object)) return; - if (!componentImports.has(path.node.object.name)) + if (!importInfos.has(path.node.object.name)) return; if (!t.isIdentifier(path.node.property)) return; @@ -108,25 +126,10 @@ export default declare((api: BabelAPI) => { return result; }); -export function collectComponentUsages(node: T.Node) { - const importedLocalNames = new Set(); +function collectJsxComponentUsages(node: T.Node): Set { const names = new Set(); traverse(node, { enter: p => { - - // First look at all the imports. - if (t.isImportDeclaration(p.node)) { - const importNode = p.node; - if (!t.isStringLiteral(importNode.source)) - return; - - for (const specifier of importNode.specifiers) { - if (t.isImportNamespaceSpecifier(specifier)) - continue; - importedLocalNames.add(specifier.local.name); - } - } - // Treat JSX-everything as component usages. if (t.isJSXElement(p.node)) { if (t.isJSXIdentifier(p.node.openingElement.name)) @@ -134,30 +137,19 @@ export function collectComponentUsages(node: T.Node) { 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); } - - // 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') { - const callExpression = p.node.argument; - const arg = callExpression.arguments[0]; - if (!t.isIdentifier(arg) || !importedLocalNames.has(arg.name)) - return; - - names.add(arg.name); - } } }); - return { names }; + return names; } export type ImportInfo = { id: string; isModuleOrAlias: boolean; importPath: string; - localName: string; remoteName: string | undefined; }; -export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, filename: string): ImportInfo { +export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportSpecifier | T.ImportDefaultSpecifier, filename: string): { localName: string, info: ImportInfo } { const importSource = importNode.source.value; const isModuleOrAlias = !importSource.startsWith('.'); const unresolvedImportPath = path.resolve(path.dirname(filename), importSource); @@ -171,7 +163,6 @@ export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportS id: idPrefix, importPath, isModuleOrAlias, - localName: specifier.local.name, remoteName: undefined, }; @@ -184,5 +175,7 @@ export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportS if (result.remoteName) result.id += '_' + result.remoteName; - return result; + return { localName: specifier.local.name, info: result }; } + +const allJsExtensions = new Set(['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx', '.cts', '.mts', '']); diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index 145179416029d..333a7d8b07f3e 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -25,12 +25,10 @@ import { debug } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; -import { parse, traverse, types as t } from 'playwright/lib/transform/babelBundle'; 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, importInfo } from './tsxTransform'; +import { getUserData, internalDependenciesForTestFile, setExternalDependencies } from 'playwright/lib/transform/compilationCache'; import { version as viteVersion, build, preview, mergeConfig } from 'vite'; import { source as injectedSource } from './generated/indexSource'; import type { ImportInfo } from './tsxTransform'; @@ -40,14 +38,6 @@ 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; @@ -131,16 +121,16 @@ export function createPlugin( viteVersion, registerSourceHash, components: [], - tests: {}, sources: {}, + deps: {}, }; } log('build exists:', buildExists); const componentRegistry: ComponentRegistry = new Map(); - // 1. Re-parse changed tests and collect required components. - const hasNewTests = await checkNewTests(suite, buildInfo, componentRegistry); - log('has new tests:', hasNewTests); + const componentsByImportingFile = new Map(); + // 1. Populate component registry based on tests' component imports. + await populateComponentsFromTests(componentRegistry, componentsByImportingFile); // 2. Check if the set of required components has changed. const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry); @@ -177,8 +167,9 @@ export function createPlugin( frameworkOverrides.plugins = [await frameworkPluginFactory()]; // But only add out own plugin when we actually build / transform. + const depsCollector = new Map(); if (sourcesDirty) - frameworkOverrides.plugins!.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry)); + frameworkOverrides.plugins!.push(vitePlugin(registerSource, templateDir, buildInfo, componentRegistry, depsCollector)); frameworkOverrides.build = { target: 'esnext', @@ -198,20 +189,31 @@ export function createPlugin( log('build'); await build(finalConfig); await fs.promises.rename(`${finalConfig.build.outDir}/${relativeTemplateDir}/index.html`, `${finalConfig.build.outDir}/index.html`); + buildInfo.deps = Object.fromEntries(depsCollector.entries()); } - if (hasNewTests || hasNewComponents || sourcesDirty) { + if (hasNewComponents || sourcesDirty) { log('write manifest'); await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2)); } - for (const [filename, testInfo] of Object.entries(buildInfo.tests)) { - const deps = new Set(); - for (const componentName of testInfo.components) { - const component = componentRegistry.get(componentName); - component?.deps.forEach(d => deps.add(d)); + for (const projectSuite of suite.suites) { + for (const fileSuite of projectSuite.suites) { + // For every test file... + const testFile = fileSuite.location!.file; + const deps = new Set(); + // Collect its JS dependencies (helpers). + for (const file of [testFile, ...(internalDependenciesForTestFile(testFile) || [])]) { + // For each helper, get all the imported components. + for (const componentFile of componentsByImportingFile.get(file) || []) { + // For each component, get all the dependencies. + for (const d of depsCollector.get(componentFile) || []) + deps.add(d); + } + } + // Now we have test file => all components along with dependencies. + setExternalDependencies(testFile, [...deps]); } - setExternalDependencies(filename, [...deps]); } const previewServer = await preview(finalConfig); @@ -240,16 +242,13 @@ type BuildInfo = { timestamp: number; } }; - components: ComponentInfo[]; - tests: { - [key: string]: { - timestamp: number; - components: string[]; - } - }; + components: ImportInfo[]; + deps: { + [key: string]: string[]; + } }; -type ComponentRegistry = Map; +type ComponentRegistry = Map; async function checkSources(buildInfo: BuildInfo): Promise { for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) { @@ -267,35 +266,13 @@ async function checkSources(buildInfo: BuildInfo): Promise { return false; } -async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise { - const testFiles = new Set(); - for (const project of suite.suites) { - for (const file of project.suites) - testFiles.add(file.location!.file); +async function populateComponentsFromTests(componentRegistry: ComponentRegistry, componentsByImportingFile: Map) { + const importInfos: Map = await getUserData('playwright-ct-core'); + for (const [file, importList] of importInfos) { + for (const importInfo of importList) + componentRegistry.set(importInfo.id, importInfo); + componentsByImportingFile.set(file, importList.filter(i => !i.isModuleOrAlias).map(i => i.importPath)); } - - let hasNewTests = false; - for (const testFile of testFiles) { - const timestamp = (await fs.promises.stat(testFile)).mtimeMs; - if (buildInfo.tests[testFile]?.timestamp !== timestamp) { - const componentImports = await parseTestFile(testFile); - log('changed test:', testFile); - 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; - } - } - - return hasNewTests; } async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise { @@ -315,36 +292,7 @@ async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: Compo return hasNewComponents; } -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: ImportInfo[] = []; - - traverse(ast, { - enter: p => { - if (t.isImportDeclaration(p.node)) { - const importNode = p.node; - if (!t.isStringLiteral(importNode.source)) - return; - - for (const specifier of importNode.specifiers) { - if (t.isImportNamespaceSpecifier(specifier)) - continue; - const info = importInfo(importNode, specifier, testFile); - if (!componentNames.has(info.localName)) - continue; - result.push(info); - } - } - } - }); - - return result; -} - -function vitePlugin(registerSource: string, templateDir: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin { +function vitePlugin(registerSource: string, templateDir: string, buildInfo: BuildInfo, importInfos: Map, depsCollector: Map): Plugin { buildInfo.sources = {}; let moduleResolver: ResolveFn; return { @@ -384,12 +332,12 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil const lines = [content, '']; lines.push(registerSource); - for (const [alias, value] of componentRegistry) { + for (const value of importInfos.values()) { const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/'); - lines.push(`const ${alias} = () => import('${importPath}').then((mod) => mod.${value.remoteName || 'default'});`); + lines.push(`const ${value.id} = () => import('${importPath}').then((mod) => mod.${value.remoteName || 'default'});`); } - lines.push(`__pwRegistry.initialize({ ${[...componentRegistry.keys()].join(',\n ')} });`); + lines.push(`__pwRegistry.initialize({ ${[...importInfos.keys()].join(',\n ')} });`); return { code: lines.join('\n'), map: { mappings: '' } @@ -397,13 +345,13 @@ function vitePlugin(registerSource: string, templateDir: string, buildInfo: Buil }, async writeBundle(this: PluginContext) { - for (const component of componentRegistry.values()) { - const id = (await moduleResolver(component.importPath)); + for (const importInfo of importInfos.values()) { + const deps = new Set(); + const id = await moduleResolver(importInfo.importPath); if (!id) continue; - const deps = new Set(); collectViteModuleDependencies(this, id, deps); - component.deps = [...deps]; + depsCollector.set(importInfo.importPath, [...deps]); } }, }; @@ -423,7 +371,7 @@ function collectViteModuleDependencies(context: PluginContext, id: string, deps: collectViteModuleDependencies(context, importedId, deps); } -function hasJSComponents(components: ComponentInfo[]): boolean { +function hasJSComponents(components: ImportInfo[]): boolean { for (const component of components) { const extname = path.extname(component.importPath); if (extname === '.js' || !extname && fs.existsSync(component.importPath + '.js')) diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 5bde599296cdb..7c3e9cae7ddd0 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -24,6 +24,7 @@ "./lib/transform/babelBundle": "./lib/transform/babelBundle.js", "./lib/transform/compilationCache": "./lib/transform/compilationCache.js", "./lib/transform/esmLoader": "./lib/transform/esmLoader.js", + "./lib/transform/transform": "./lib/transform/transform.js", "./lib/internalsForTest": "./lib/internalsForTest.js", "./lib/plugins": "./lib/plugins/index.js", "./jsx-runtime": { diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index fcc96de26b418..c1e97bddb7b9a 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -23,9 +23,17 @@ import { isWorkerProcess } from '../common/globals'; export type MemoryCache = { codePath: string; sourceMapPath: string; + dataPath: string; moduleUrl?: string; }; +type SerializedCompilationCache = { + sourceMaps: [string, string][], + memoryCache: [string, MemoryCache][], + fileDependencies: [string, string[]][], + externalDependencies: [string, string[]][], +}; + // Assumptions for the compilation cache: // - Files in the temp directory we work with can disappear at any moment, either some of them or all together. // - Multiple workers can be trying to read from the compilation cache at the same time. @@ -84,12 +92,12 @@ export function installSourceMapSupportIfNeeded() { }); } -function _innerAddToCompilationCache(filename: string, options: { codePath: string, sourceMapPath: string, moduleUrl?: string }) { - sourceMaps.set(options.moduleUrl || filename, options.sourceMapPath); - memoryCache.set(filename, options); +function _innerAddToCompilationCache(filename: string, entry: MemoryCache) { + sourceMaps.set(entry.moduleUrl || filename, entry.sourceMapPath); + memoryCache.set(filename, entry); } -export function getFromCompilationCache(filename: string, hash: string, moduleUrl?: string): { cachedCode?: string, addToCache?: (code: string, map?: any) => void } { +export function getFromCompilationCache(filename: string, hash: string, moduleUrl?: string): { cachedCode?: string, addToCache?: (code: string, map: any | undefined | null, data: Map) => void } { // First check the memory cache by filename, this cache will always work in the worker, // because we just compiled this file in the loader. const cache = memoryCache.get(filename); @@ -105,27 +113,30 @@ export function getFromCompilationCache(filename: string, hash: string, moduleUr const cachePath = calculateCachePath(filename, hash); const codePath = cachePath + '.js'; const sourceMapPath = cachePath + '.map'; + const dataPath = cachePath + '.data'; try { const cachedCode = fs.readFileSync(codePath, 'utf8'); - _innerAddToCompilationCache(filename, { codePath, sourceMapPath, moduleUrl }); + _innerAddToCompilationCache(filename, { codePath, sourceMapPath, dataPath, moduleUrl }); return { cachedCode }; } catch { } return { - addToCache: (code: string, map: any) => { + addToCache: (code: string, map: any | undefined | null, data: Map) => { if (isWorkerProcess()) return; fs.mkdirSync(path.dirname(cachePath), { recursive: true }); if (map) fs.writeFileSync(sourceMapPath, JSON.stringify(map), 'utf8'); + if (data.size) + fs.writeFileSync(dataPath, JSON.stringify(Object.fromEntries(data.entries()), undefined, 2), 'utf8'); fs.writeFileSync(codePath, code, 'utf8'); - _innerAddToCompilationCache(filename, { codePath, sourceMapPath, moduleUrl }); + _innerAddToCompilationCache(filename, { codePath, sourceMapPath, dataPath, moduleUrl }); } }; } -export function serializeCompilationCache(): any { +export function serializeCompilationCache(): SerializedCompilationCache { return { sourceMaps: [...sourceMaps.entries()], memoryCache: [...memoryCache.entries()], @@ -200,6 +211,10 @@ export function collectAffectedTestFiles(dependency: string, testFileCollector: } } +export function internalDependenciesForTestFile(filename: string): Set | undefined{ + return fileDependencies.get(filename); +} + export function dependenciesForTestFile(filename: string): Set { const result = new Set(); for (const dep of fileDependencies.get(filename) || []) @@ -224,3 +239,17 @@ export function belongsToNodeModules(file: string) { return true; return false; } + +export async function getUserData(pluginName: string): Promise> { + const result = new Map(); + for (const [fileName, cache] of memoryCache) { + if (!cache.dataPath) + continue; + if (!fs.existsSync(cache.dataPath)) + continue; + const data = JSON.parse(await fs.promises.readFile(cache.dataPath, 'utf8')); + if (data[pluginName]) + result.set(fileName, data[pluginName]); + } + return result; +} diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index e011e7fb2d9e1..29b8b78a2b490 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -16,8 +16,8 @@ import crypto from 'crypto'; import path from 'path'; -import { sourceMapSupport, pirates } from '../utilsBundle'; import url from 'url'; +import { sourceMapSupport, pirates } from '../utilsBundle'; import type { Location } from '../../types/testReporter'; import type { TsConfigLoaderResult } from '../third_party/tsconfig-loader'; import { tsConfigLoader } from '../third_party/tsconfig-loader'; @@ -159,6 +159,12 @@ export function shouldTransform(filename: string): boolean { return !belongsToNodeModules(filename); } +let transformData: Map; + +export function setTransformData(pluginName: string, value: any) { + transformData.set(pluginName, value); +} + export function transformHook(originalCode: string, filename: string, moduleUrl?: string): string { const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts'); const hasPreprocessor = @@ -177,9 +183,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'; const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); + transformData = new Map(); const { code, map } = babelTransform(originalCode, filename, isTypeScript, !!moduleUrl, pluginsPrologue, pluginsEpilogue); if (code) - addToCache!(code, map); + addToCache!(code, map, transformData); return code || ''; } diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index 1a302b93ddc53..605205059ec45 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -76,3 +76,4 @@ test('drag resize', async ({ page, mount }) => { expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 100 }); expect.soft(sidebarBox).toEqual({ x: 0, y: 101, width: 500, height: 399 }); }); + diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index ff81c9e6f20b4..f6eafda10073d 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -76,6 +76,8 @@ export async function writeFiles(testInfo: TestInfo, files: Files, initial: bool await Promise.all(Object.keys(files).map(async name => { const fullName = path.join(baseDir, name); + if (files[name] === undefined) + return; await fs.promises.mkdir(path.dirname(fullName), { recursive: true }); await fs.promises.writeFile(fullName, files[name]); })); diff --git a/tests/playwright-test/playwright.ct-build.spec.ts b/tests/playwright-test/playwright.ct-build.spec.ts index 26b430e87027e..ca47163327d2c 100644 --- a/tests/playwright-test/playwright.ct-build.spec.ts +++ b/tests/playwright-test/playwright.ct-build.spec.ts @@ -44,7 +44,7 @@ test('should work with the empty component list', async ({ runInlineTest }, test const metainfo = JSON.parse(fs.readFileSync(testInfo.outputPath('playwright/.cache/metainfo.json'), 'utf-8')); expect(metainfo.version).toEqual(require('playwright-core/package.json').version); expect(metainfo.viteVersion).toEqual(require('vite/package.json').version); - expect(Object.entries(metainfo.tests)).toHaveLength(1); + expect(Object.entries(metainfo.deps)).toHaveLength(0); expect(Object.entries(metainfo.sources)).toHaveLength(9); }); @@ -139,92 +139,57 @@ test('should extract component list', async ({ runInlineTest }, testInfo) => { remoteName: 'Button', importPath: expect.stringContaining('button.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('button.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }, { id: expect.stringContaining('playwright_test_src_clashingNames1_tsx_ClashingName'), remoteName: 'ClashingName', importPath: expect.stringContaining('clashingNames1.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('clashingNames1.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }, { id: expect.stringContaining('playwright_test_src_clashingNames2_tsx_ClashingName'), remoteName: 'ClashingName', importPath: expect.stringContaining('clashingNames2.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('clashingNames2.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }, { id: expect.stringContaining('playwright_test_src_components_tsx_Component1'), remoteName: 'Component1', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('components.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }, { id: expect.stringContaining('playwright_test_src_components_tsx_Component2'), remoteName: 'Component2', importPath: expect.stringContaining('components.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('components.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }, { id: expect.stringContaining('playwright_test_src_defaultExport_tsx'), importPath: expect.stringContaining('defaultExport.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('defaultExport.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }]); - for (const [file, test] of Object.entries(metainfo.tests)) { - if (file.endsWith('clashing-imports.spec.tsx')) { - expect(test).toEqual({ - timestamp: expect.any(Number), - components: [ - expect.stringContaining('clashingNames1_tsx_ClashingName'), - expect.stringContaining('clashingNames2_tsx_ClashingName'), - ], - }); - } - if (file.endsWith('default-import.spec.tsx')) { - expect(test).toEqual({ - timestamp: expect.any(Number), - components: [ - expect.stringContaining('defaultExport_tsx'), - ], - }); - } - if (file.endsWith('named-imports.spec.tsx')) { - expect(test).toEqual({ - timestamp: expect.any(Number), - components: [ - expect.stringContaining('components_tsx_Component1'), - expect.stringContaining('components_tsx_Component2'), - ], - }); - } - if (file.endsWith('one-import.spec.tsx')) { - expect(test).toEqual({ - timestamp: expect.any(Number), - components: [ - expect.stringContaining('button_tsx_Button'), - ], - }); - } - } + for (const [, value] of Object.entries(metainfo.deps)) + (value as string[]).sort(); + + expect(Object.entries(metainfo.deps)).toEqual([ + [expect.stringContaining('clashingNames1.tsx'), [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('clashingNames1.tsx'), + ]], + [expect.stringContaining('clashingNames2.tsx'), [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('clashingNames2.tsx'), + ]], + [expect.stringContaining('defaultExport.tsx'), [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('defaultExport.tsx'), + ]], + [expect.stringContaining('components.tsx'), [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('components.tsx'), + ]], + [expect.stringContaining('button.tsx'), [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('button.tsx'), + ]], + ]); }); test('should cache build', async ({ runInlineTest }, testInfo) => { @@ -495,21 +460,74 @@ test('should retain deps when test changes', async ({ runInlineTest }, testInfo) remoteName: 'Button', importPath: expect.stringContaining('button.tsx'), isModuleOrAlias: false, - deps: [ - expect.stringContaining('button.tsx'), - expect.stringContaining('jsx-runtime.js'), - ] }]); - expect(Object.entries(metainfo.tests)).toEqual([ + for (const [, value] of Object.entries(metainfo.deps)) + (value as string[]).sort(); + + expect(Object.entries(metainfo.deps)).toEqual([ [ - expect.stringContaining('button.test.tsx'), - { - components: [ - expect.stringContaining('src_button_tsx_Button'), - ], - timestamp: expect.any(Number) - } + expect.stringContaining('button.tsx'), + [ + expect.stringContaining('jsx-runtime.js'), + expect.stringContaining('button.tsx'), + ], ] ]); }); + +test('should render component via re-export', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightConfig, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.tsx': ` + export const Button = () => ; + `, + 'src/buttonHelper.ts': ` + import { Button } from './button.tsx'; + export { Button }; + `, + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './buttonHelper'; + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button'); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should render component exported via fixture', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightConfig, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/button.tsx': ` + export const Button = () => ; + `, + 'src/buttonFixture.tsx': ` + import { Button } from './button'; + import { test as baseTest } from '@playwright/experimental-ct-react'; + export { expect } from '@playwright/experimental-ct-react'; + export const test = baseTest.extend({ + button: async ({ mount }, use) => { + await use(await mount()); + } + }); + `, + 'src/button.test.tsx': ` + import { test, expect } from './buttonFixture'; + test('pass', async ({ button }) => { + await expect(button).toHaveText('Button'); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); diff --git a/tests/playwright-test/ui-mode-test-ct.spec.ts b/tests/playwright-test/ui-mode-test-ct.spec.ts index 13cfb49d56aa5..12c3cda430c04 100644 --- a/tests/playwright-test/ui-mode-test-ct.spec.ts +++ b/tests/playwright-test/ui-mode-test-ct.spec.ts @@ -207,3 +207,81 @@ test('should watch component', async ({ runUITest, writeFiles }) => { ❌ pass <= `); }); + +test('should watch component via util', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest({ + ...basicTestTree, + 'src/button.tsx': undefined, + 'src/button.ts': ` + import { Button } from './buttonComponent'; + export { Button }; + `, + 'src/buttonComponent.tsx': ` + export const Button = () => ; + `, + }); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + + await page.getByTitle('Watch all').click(); + await page.getByTitle('Run all').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/buttonComponent.tsx': ` + export const Button = () => ; + ` + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ pass <= + `); +}); + +test('should watch component when editing util', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest({ + ...basicTestTree, + 'src/button.tsx': undefined, + 'src/button.ts': ` + import { Button } from './buttonComponent'; + export { Button }; + `, + 'src/buttonComponent.tsx': ` + export const Button = () => ; + `, + 'src/buttonComponent2.tsx': ` + export const Button = () => ; + `, + }); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ button.test.tsx + ◯ pass + `); + + await page.getByTitle('Watch all').click(); + await page.getByTitle('Run all').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ button.test.tsx + ✅ pass + `); + + await writeFiles({ + 'src/button.ts': ` + import { Button } from './buttonComponent2'; + export { Button }; + `, + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ❌ button.test.tsx + ❌ pass <= + `); +});