Browse Source

feat(ct): only rebuild when necessary (#14026)

pull/14038/head
Pavel Feldman 2 months ago committed by GitHub
parent
commit
46e82e8fea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .eslintignore
  2. 2
      .gitignore
  3. 231
      packages/playwright-test/src/plugins/vitePlugin.ts

3
.eslintignore

@ -15,4 +15,5 @@ output/
test-results/
tests/components/
examples/
DEPS
DEPS
.cache/

2
.gitignore vendored

@ -28,4 +28,4 @@ test-results
.env
/tests/installation/output/
/tests/installation/.registry.json
/playwright/out/
.cache/

231
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<string>();
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<string, ComponentInfo> = new Map();
type BuildInfo = {
version: number,
sources: {
[key: string]: {
timestamp: number;
}
};
components: ComponentInfo[];
tests: {
[key: string]: {
timestamp: number;
components: string[];
}
};
};
type ComponentRegistry = Map<string, ComponentInfo>;
async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
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<boolean> {
const testFiles = new Set<string>();
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<boolean> {
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<ComponentInfo[]> {
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;
function vitePlugin(registerSource: string, files: string[]): Plugin {
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.
}
}
},
transform: async (content, id) => {
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: '' }

Loading…
Cancel
Save