From 46e82e8fea476cfb3ccb45a8f838502de76d67ba Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 9 May 2022 08:10:47 -0800 Subject: [PATCH] feat(ct): only rebuild when necessary (#14026) --- .eslintignore | 3 +- .gitignore | 2 +- .../playwright-test/src/plugins/vitePlugin.ts | 233 +++++++++++++----- 3 files changed, 176 insertions(+), 62 deletions(-) diff --git a/.eslintignore b/.eslintignore index 0078ec34c3..006f77c58c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,4 +15,5 @@ output/ test-results/ tests/components/ examples/ -DEPS \ No newline at end of file +DEPS +.cache/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index d072246c44..deea03bdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,4 @@ test-results .env /tests/installation/output/ /tests/installation/.registry.json -/playwright/out/ \ No newline at end of file +.cache/ diff --git a/packages/playwright-test/src/plugins/vitePlugin.ts b/packages/playwright-test/src/plugins/vitePlugin.ts index 74afafd2cf..b2658f08bd 100644 --- a/packages/playwright-test/src/plugins/vitePlugin.ts +++ b/packages/playwright-test/src/plugins/vitePlugin.ts @@ -23,8 +23,10 @@ import { parse, traverse, types as t } from '../babelBundle'; import type { ComponentInfo } from '../tsxTransform'; import { collectComponentUsages, componentInfo } from '../tsxTransform'; import type { FullConfig } from '../types'; +import { assert } from 'playwright-core/lib/utils'; let previewServer: PreviewServer; +const VERSION = 1; export function createPlugin( registerSourceFile: string, @@ -37,42 +39,68 @@ export function createPlugin( const use = config.projects[0].use as any; const viteConfig: InlineConfig = use.viteConfig || {}; const port = use.vitePort || 3100; - configDir = configDirectory; - process.env.PLAYWRIGHT_VITE_COMPONENTS_BASE_URL = `http://localhost:${port}/playwright/index.html`; - viteConfig.root = viteConfig.root || configDir; - viteConfig.plugins = viteConfig.plugins || [ - frameworkPluginFactory() - ]; - const files = new Set(); - for (const project of suite.suites) { - for (const file of project.suites) - files.add(file.location!.file); + const rootDir = viteConfig.root || configDir; + const outDir = viteConfig?.build?.outDir || path.join(rootDir, 'playwright', '.cache'); + const templateDir = path.join(rootDir, 'playwright'); + + const buildInfoFile = path.join(outDir, 'metainfo.json'); + let buildInfo: BuildInfo; + try { + buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo; + assert(buildInfo.version === VERSION); + } catch (e) { + buildInfo = { + version: VERSION, + components: [], + tests: {}, + sources: {}, + }; } - const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8'); - viteConfig.plugins.push(vitePlugin(registerSource, [...files])); - viteConfig.configFile = viteConfig.configFile || false; - viteConfig.define = viteConfig.define || {}; - viteConfig.define.__VUE_PROD_DEVTOOLS__ = true; - viteConfig.css = viteConfig.css || {}; - viteConfig.css.devSourcemap = true; + + const componentRegistry: ComponentRegistry = new Map(); + // 1. Re-parse changed tests and collect required components. + const hasNewTests = await checkNewTests(suite, buildInfo, componentRegistry); + // 2. Check if the set of required components has changed. + const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry); + // 3. Check component sources. + const sourcesDirty = hasNewComponents || await checkSources(buildInfo); + + viteConfig.root = rootDir; viteConfig.preview = { port }; viteConfig.build = { - target: 'esnext', - minify: false, - rollupOptions: { - treeshake: false, - input: { - index: path.join(viteConfig.root, 'playwright', 'index.html') - }, - }, - sourcemap: true, - outDir: viteConfig?.build?.outDir || path.join(viteConfig.root, 'playwright', 'out') + outDir }; const { build, preview } = require('vite'); - await build(viteConfig); + if (sourcesDirty) { + viteConfig.plugins = viteConfig.plugins || [ + frameworkPluginFactory() + ]; + const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8'); + viteConfig.plugins.push(vitePlugin(registerSource, buildInfo, componentRegistry)); + viteConfig.configFile = viteConfig.configFile || false; + viteConfig.define = viteConfig.define || {}; + viteConfig.define.__VUE_PROD_DEVTOOLS__ = true; + viteConfig.css = viteConfig.css || {}; + viteConfig.css.devSourcemap = true; + viteConfig.build = { + ...viteConfig.build, + target: 'esnext', + minify: false, + rollupOptions: { + treeshake: false, + input: { + index: path.join(templateDir, 'index.html') + }, + }, + sourcemap: true, + }; + await build(viteConfig); + } + if (hasNewTests || hasNewComponents || sourcesDirty) + await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2)); previewServer = await preview(viteConfig); }, @@ -87,41 +115,126 @@ export function createPlugin( }; } -const imports: Map = new Map(); +type BuildInfo = { + version: number, + sources: { + [key: string]: { + timestamp: number; + } + }; + components: ComponentInfo[]; + tests: { + [key: string]: { + timestamp: number; + components: string[]; + } + }; +}; -function vitePlugin(registerSource: string, files: string[]): Plugin { +type ComponentRegistry = Map; + +async function checkSources(buildInfo: BuildInfo): Promise { + for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) { + try { + const timestamp = (await fs.promises.stat(source)).mtimeMs; + if (sourceInfo.timestamp !== timestamp) + return true; + } catch (e) { + return true; + } + } + 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); + } + + let hasNewTests = false; + for (const testFile of testFiles) { + const timestamp = (await fs.promises.stat(testFile)).mtimeMs; + if (buildInfo.tests[testFile]?.timestamp !== timestamp) { + const components = await parseTestFile(testFile); + for (const component of components) + componentRegistry.set(component.fullName, component); + buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) }; + hasNewTests = true; + } else { + // The test has not changed, populate component registry from the buildInfo. + for (const componentName of buildInfo.tests[testFile].components) { + const component = buildInfo.components.find(c => c.fullName === componentName)!; + componentRegistry.set(component.fullName, component); + } + } + } + + return hasNewTests; +} + +async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise { + const newComponents = [...componentRegistry.keys()]; + const oldComponents = new Set(buildInfo.components.map(c => c.fullName)); + + let hasNewComponents = false; + for (const c of newComponents) { + if (!oldComponents.has(c)) { + hasNewComponents = true; + break; + } + } + if (!hasNewComponents) + return false; + buildInfo.components = newComponents.map(n => componentRegistry.get(n)!); + return true; +} + +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 result: ComponentInfo[] = []; + + 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 (!componentUsages.names.has(specifier.local.name)) + continue; + if (t.isImportNamespaceSpecifier(specifier)) + continue; + result.push(componentInfo(specifier, importNode.source.value, testFile)); + } + } + } + }); + + return result; +} + +function vitePlugin(registerSource: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin { + buildInfo.sources = {}; return { name: 'playwright:component-index', - configResolved: async config => { - - for (const file of files) { - const text = await fs.promises.readFile(file, 'utf-8'); - const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' }); - const components = collectComponentUsages(ast); - - 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 (!components.names.has(specifier.local.name)) - continue; - if (t.isImportNamespaceSpecifier(specifier)) - continue; - const info = componentInfo(specifier, importNode.source.value, file); - imports.set(info.fullName, info); - } - } - } - }); - } - }, - transform: async (content, id) => { + const queryIndex = id.indexOf('?'); + const file = queryIndex !== -1 ? id.substring(0, queryIndex) : id; + if (!buildInfo.sources[file]) { + try { + const timestamp = (await fs.promises.stat(file)).mtimeMs; + buildInfo.sources[file] = { timestamp }; + } catch { + // Silent if can't read the file. + } + } + if (!id.endsWith('playwright/index.ts') && !id.endsWith('playwright/index.tsx') && !id.endsWith('playwright/index.js')) return; @@ -129,7 +242,7 @@ function vitePlugin(registerSource: string, files: string[]): Plugin { const lines = [content, '']; lines.push(registerSource); - for (const [alias, value] of imports) { + for (const [alias, value] of componentRegistry) { const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/'); if (value.importedName) lines.push(`import { ${value.importedName} as ${alias} } from '${importPath}';`); @@ -137,7 +250,7 @@ function vitePlugin(registerSource: string, files: string[]): Plugin { lines.push(`import ${alias} from '${importPath}';`); } - lines.push(`register({ ${[...imports.keys()].join(',\n ')} });`); + lines.push(`register({ ${[...componentRegistry.keys()].join(',\n ')} });`); return { code: lines.join('\n'), map: { mappings: '' }