Skip to content

Commit 46e82e8

Browse files
authored
feat(ct): only rebuild when necessary (microsoft#14026)
1 parent 5aa82dc commit 46e82e8

File tree

3 files changed

+175
-61
lines changed

3 files changed

+175
-61
lines changed

.eslintignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ output/
1515
test-results/
1616
tests/components/
1717
examples/
18-
DEPS
18+
DEPS
19+
.cache/

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ test-results
2828
.env
2929
/tests/installation/output/
3030
/tests/installation/.registry.json
31-
/playwright/out/
31+
.cache/

packages/playwright-test/src/plugins/vitePlugin.ts

+172-59
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import { parse, traverse, types as t } from '../babelBundle';
2323
import type { ComponentInfo } from '../tsxTransform';
2424
import { collectComponentUsages, componentInfo } from '../tsxTransform';
2525
import type { FullConfig } from '../types';
26+
import { assert } from 'playwright-core/lib/utils';
2627

2728
let previewServer: PreviewServer;
29+
const VERSION = 1;
2830

2931
export function createPlugin(
3032
registerSourceFile: string,
@@ -37,42 +39,68 @@ export function createPlugin(
3739
const use = config.projects[0].use as any;
3840
const viteConfig: InlineConfig = use.viteConfig || {};
3941
const port = use.vitePort || 3100;
40-
4142
configDir = configDirectory;
42-
4343
process.env.PLAYWRIGHT_VITE_COMPONENTS_BASE_URL = `http://localhost:${port}/playwright/index.html`;
4444

45-
viteConfig.root = viteConfig.root || configDir;
46-
viteConfig.plugins = viteConfig.plugins || [
47-
frameworkPluginFactory()
48-
];
49-
const files = new Set<string>();
50-
for (const project of suite.suites) {
51-
for (const file of project.suites)
52-
files.add(file.location!.file);
45+
const rootDir = viteConfig.root || configDir;
46+
const outDir = viteConfig?.build?.outDir || path.join(rootDir, 'playwright', '.cache');
47+
const templateDir = path.join(rootDir, 'playwright');
48+
49+
const buildInfoFile = path.join(outDir, 'metainfo.json');
50+
let buildInfo: BuildInfo;
51+
try {
52+
buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo;
53+
assert(buildInfo.version === VERSION);
54+
} catch (e) {
55+
buildInfo = {
56+
version: VERSION,
57+
components: [],
58+
tests: {},
59+
sources: {},
60+
};
5361
}
54-
const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8');
55-
viteConfig.plugins.push(vitePlugin(registerSource, [...files]));
56-
viteConfig.configFile = viteConfig.configFile || false;
57-
viteConfig.define = viteConfig.define || {};
58-
viteConfig.define.__VUE_PROD_DEVTOOLS__ = true;
59-
viteConfig.css = viteConfig.css || {};
60-
viteConfig.css.devSourcemap = true;
62+
63+
const componentRegistry: ComponentRegistry = new Map();
64+
// 1. Re-parse changed tests and collect required components.
65+
const hasNewTests = await checkNewTests(suite, buildInfo, componentRegistry);
66+
// 2. Check if the set of required components has changed.
67+
const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry);
68+
// 3. Check component sources.
69+
const sourcesDirty = hasNewComponents || await checkSources(buildInfo);
70+
71+
viteConfig.root = rootDir;
6172
viteConfig.preview = { port };
6273
viteConfig.build = {
63-
target: 'esnext',
64-
minify: false,
65-
rollupOptions: {
66-
treeshake: false,
67-
input: {
68-
index: path.join(viteConfig.root, 'playwright', 'index.html')
69-
},
70-
},
71-
sourcemap: true,
72-
outDir: viteConfig?.build?.outDir || path.join(viteConfig.root, 'playwright', 'out')
74+
outDir
7375
};
7476
const { build, preview } = require('vite');
75-
await build(viteConfig);
77+
if (sourcesDirty) {
78+
viteConfig.plugins = viteConfig.plugins || [
79+
frameworkPluginFactory()
80+
];
81+
const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8');
82+
viteConfig.plugins.push(vitePlugin(registerSource, buildInfo, componentRegistry));
83+
viteConfig.configFile = viteConfig.configFile || false;
84+
viteConfig.define = viteConfig.define || {};
85+
viteConfig.define.__VUE_PROD_DEVTOOLS__ = true;
86+
viteConfig.css = viteConfig.css || {};
87+
viteConfig.css.devSourcemap = true;
88+
viteConfig.build = {
89+
...viteConfig.build,
90+
target: 'esnext',
91+
minify: false,
92+
rollupOptions: {
93+
treeshake: false,
94+
input: {
95+
index: path.join(templateDir, 'index.html')
96+
},
97+
},
98+
sourcemap: true,
99+
};
100+
await build(viteConfig);
101+
}
102+
if (hasNewTests || hasNewComponents || sourcesDirty)
103+
await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
76104
previewServer = await preview(viteConfig);
77105
},
78106

@@ -87,57 +115,142 @@ export function createPlugin(
87115
};
88116
}
89117

90-
const imports: Map<string, ComponentInfo> = new Map();
118+
type BuildInfo = {
119+
version: number,
120+
sources: {
121+
[key: string]: {
122+
timestamp: number;
123+
}
124+
};
125+
components: ComponentInfo[];
126+
tests: {
127+
[key: string]: {
128+
timestamp: number;
129+
components: string[];
130+
}
131+
};
132+
};
133+
134+
type ComponentRegistry = Map<string, ComponentInfo>;
135+
136+
async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
137+
for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) {
138+
try {
139+
const timestamp = (await fs.promises.stat(source)).mtimeMs;
140+
if (sourceInfo.timestamp !== timestamp)
141+
return true;
142+
} catch (e) {
143+
return true;
144+
}
145+
}
146+
return false;
147+
}
148+
149+
async function checkNewTests(suite: Suite, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
150+
const testFiles = new Set<string>();
151+
for (const project of suite.suites) {
152+
for (const file of project.suites)
153+
testFiles.add(file.location!.file);
154+
}
155+
156+
let hasNewTests = false;
157+
for (const testFile of testFiles) {
158+
const timestamp = (await fs.promises.stat(testFile)).mtimeMs;
159+
if (buildInfo.tests[testFile]?.timestamp !== timestamp) {
160+
const components = await parseTestFile(testFile);
161+
for (const component of components)
162+
componentRegistry.set(component.fullName, component);
163+
buildInfo.tests[testFile] = { timestamp, components: components.map(c => c.fullName) };
164+
hasNewTests = true;
165+
} else {
166+
// The test has not changed, populate component registry from the buildInfo.
167+
for (const componentName of buildInfo.tests[testFile].components) {
168+
const component = buildInfo.components.find(c => c.fullName === componentName)!;
169+
componentRegistry.set(component.fullName, component);
170+
}
171+
}
172+
}
173+
174+
return hasNewTests;
175+
}
176+
177+
async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
178+
const newComponents = [...componentRegistry.keys()];
179+
const oldComponents = new Set(buildInfo.components.map(c => c.fullName));
180+
181+
let hasNewComponents = false;
182+
for (const c of newComponents) {
183+
if (!oldComponents.has(c)) {
184+
hasNewComponents = true;
185+
break;
186+
}
187+
}
188+
if (!hasNewComponents)
189+
return false;
190+
buildInfo.components = newComponents.map(n => componentRegistry.get(n)!);
191+
return true;
192+
}
193+
194+
async function parseTestFile(testFile: string): Promise<ComponentInfo[]> {
195+
const text = await fs.promises.readFile(testFile, 'utf-8');
196+
const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' });
197+
const componentUsages = collectComponentUsages(ast);
198+
const result: ComponentInfo[] = [];
199+
200+
traverse(ast, {
201+
enter: p => {
202+
if (t.isImportDeclaration(p.node)) {
203+
const importNode = p.node;
204+
if (!t.isStringLiteral(importNode.source))
205+
return;
91206

92-
function vitePlugin(registerSource: string, files: string[]): Plugin {
207+
for (const specifier of importNode.specifiers) {
208+
if (!componentUsages.names.has(specifier.local.name))
209+
continue;
210+
if (t.isImportNamespaceSpecifier(specifier))
211+
continue;
212+
result.push(componentInfo(specifier, importNode.source.value, testFile));
213+
}
214+
}
215+
}
216+
});
217+
218+
return result;
219+
}
220+
221+
function vitePlugin(registerSource: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin {
222+
buildInfo.sources = {};
93223
return {
94224
name: 'playwright:component-index',
95225

96-
configResolved: async config => {
97-
98-
for (const file of files) {
99-
const text = await fs.promises.readFile(file, 'utf-8');
100-
const ast = parse(text, { errorRecovery: true, plugins: ['typescript', 'jsx'], sourceType: 'module' });
101-
const components = collectComponentUsages(ast);
102-
103-
traverse(ast, {
104-
enter: p => {
105-
if (t.isImportDeclaration(p.node)) {
106-
const importNode = p.node;
107-
if (!t.isStringLiteral(importNode.source))
108-
return;
109-
110-
for (const specifier of importNode.specifiers) {
111-
if (!components.names.has(specifier.local.name))
112-
continue;
113-
if (t.isImportNamespaceSpecifier(specifier))
114-
continue;
115-
const info = componentInfo(specifier, importNode.source.value, file);
116-
imports.set(info.fullName, info);
117-
}
118-
}
119-
}
120-
});
226+
transform: async (content, id) => {
227+
const queryIndex = id.indexOf('?');
228+
const file = queryIndex !== -1 ? id.substring(0, queryIndex) : id;
229+
if (!buildInfo.sources[file]) {
230+
try {
231+
const timestamp = (await fs.promises.stat(file)).mtimeMs;
232+
buildInfo.sources[file] = { timestamp };
233+
} catch {
234+
// Silent if can't read the file.
235+
}
121236
}
122-
},
123237

124-
transform: async (content, id) => {
125238
if (!id.endsWith('playwright/index.ts') && !id.endsWith('playwright/index.tsx') && !id.endsWith('playwright/index.js'))
126239
return;
127240

128241
const folder = path.dirname(id);
129242
const lines = [content, ''];
130243
lines.push(registerSource);
131244

132-
for (const [alias, value] of imports) {
245+
for (const [alias, value] of componentRegistry) {
133246
const importPath = value.isModuleOrAlias ? value.importPath : './' + path.relative(folder, value.importPath).replace(/\\/g, '/');
134247
if (value.importedName)
135248
lines.push(`import { ${value.importedName} as ${alias} } from '${importPath}';`);
136249
else
137250
lines.push(`import ${alias} from '${importPath}';`);
138251
}
139252

140-
lines.push(`register({ ${[...imports.keys()].join(',\n ')} });`);
253+
lines.push(`register({ ${[...componentRegistry.keys()].join(',\n ')} });`);
141254
return {
142255
code: lines.join('\n'),
143256
map: { mappings: '' }

0 commit comments

Comments
 (0)