fix(ct): move import list into the compilation cache data (#28986)

This commit is contained in:
Pavel Feldman 2024-01-16 19:31:19 -08:00 committed by GitHub
parent e6d51cf7bd
commit 1db18711a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 304 additions and 227 deletions

View File

@ -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<string>;
let componentImports: Map<string, ImportInfo>;
let jsxComponentNames: Set<string>;
let importInfos: Map<string, ImportInfo>;
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<string>();
function collectJsxComponentUsages(node: T.Node): Set<string> {
const names = new Set<string>();
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', '']);

View File

@ -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<string, string[]>();
// 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<string, string[]>();
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<string>();
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<string>();
// 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<string, ComponentInfo>;
type ComponentRegistry = Map<string, ImportInfo>;
async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) {
@ -267,35 +266,13 @@ async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
return false;
}
async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
const testFiles = new Set<string>();
for (const project of suite.suites) {
for (const file of project.suites)
testFiles.add(file.location!.file);
async function populateComponentsFromTests(componentRegistry: ComponentRegistry, componentsByImportingFile: Map<string, string[]>) {
const importInfos: Map<string, ImportInfo[]> = 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<boolean> {
@ -315,36 +292,7 @@ async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: Compo
return hasNewComponents;
}
async function parseTestFile(testFile: string): Promise<ImportInfo[]> {
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<string, ImportInfo>, depsCollector: Map<string, string[]>): 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<string>();
const id = await moduleResolver(importInfo.importPath);
if (!id)
continue;
const deps = new Set<string>();
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'))

View File

@ -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": {

View File

@ -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<string, any>) => 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<string, any>) => {
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<string> | undefined{
return fileDependencies.get(filename);
}
export function dependenciesForTestFile(filename: string): Set<string> {
const result = new Set<string>();
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<Map<string, any>> {
const result = new Map<string, any>();
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;
}

View File

@ -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<string, any>;
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<string, any>();
const { code, map } = babelTransform(originalCode, filename, isTypeScript, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
if (code)
addToCache!(code, map);
addToCache!(code, map, transformData);
return code || '';
}

View File

@ -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 });
});

View File

@ -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]);
}));

View File

@ -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': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</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(<Button></Button>);
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': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</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(<Button></Button>));
}
});
`,
'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);
});

View File

@ -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 = () => <button>Button</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 = () => <button>Button2</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 = () => <button>Button</button>;
`,
'src/buttonComponent2.tsx': `
export const Button = () => <button>Button2</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 <=
`);
});