2022-04-22 03:30:17 +03:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2022-05-06 22:02:07 +03:00
|
|
|
import fs from 'fs';
|
2022-05-04 00:25:56 +03:00
|
|
|
import type { Suite } from '../../types/testReporter';
|
2022-05-06 22:02:07 +03:00
|
|
|
import path from 'path';
|
2022-08-08 18:54:56 +03:00
|
|
|
import type { InlineConfig, Plugin } from 'vite';
|
2022-05-06 22:02:07 +03:00
|
|
|
import type { TestRunnerPlugin } from '.';
|
2023-01-27 04:26:47 +03:00
|
|
|
import { parse, traverse, types as t } from '../common/babelBundle';
|
2022-08-08 18:54:56 +03:00
|
|
|
import { stoppable } from '../utilsBundle';
|
2023-01-27 04:26:47 +03:00
|
|
|
import type { ComponentInfo } from '../common/tsxTransform';
|
|
|
|
import { collectComponentUsages, componentInfo } from '../common/tsxTransform';
|
|
|
|
import type { FullConfig } from '../common/types';
|
2022-06-03 03:37:43 +03:00
|
|
|
import { assert, calculateSha1 } from 'playwright-core/lib/utils';
|
2022-05-24 23:54:12 +03:00
|
|
|
import type { AddressInfo } from 'net';
|
2023-01-14 00:50:38 +03:00
|
|
|
import { getPlaywrightVersion } from 'playwright-core/lib/utils';
|
2022-10-31 22:58:07 +03:00
|
|
|
import type { PlaywrightTestConfig as BasePlaywrightTestConfig } from '@playwright/test';
|
2022-04-22 03:30:17 +03:00
|
|
|
|
2022-08-08 18:54:56 +03:00
|
|
|
let stoppableServer: any;
|
2022-09-16 01:24:01 +03:00
|
|
|
const playwrightVersion = getPlaywrightVersion();
|
2022-04-22 03:30:17 +03:00
|
|
|
|
2022-10-31 22:58:07 +03:00
|
|
|
type CtConfig = BasePlaywrightTestConfig['use'] & {
|
2022-05-10 22:21:29 +03:00
|
|
|
ctPort?: number;
|
|
|
|
ctTemplateDir?: string;
|
|
|
|
ctCacheDir?: string;
|
2023-01-28 01:13:15 +03:00
|
|
|
ctViteConfig?: InlineConfig | (() => Promise<InlineConfig>);
|
2022-05-10 22:21:29 +03:00
|
|
|
};
|
|
|
|
|
2022-06-16 02:14:45 +03:00
|
|
|
const importReactRE = /(^|\n)import\s+(\*\s+as\s+)?React(,|\s+)/;
|
2022-07-06 23:44:06 +03:00
|
|
|
const compiledReactRE = /(const|var)\s+React\s*=/;
|
2022-05-10 22:21:29 +03:00
|
|
|
|
2022-05-06 22:02:07 +03:00
|
|
|
export function createPlugin(
|
2022-05-04 07:25:50 +03:00
|
|
|
registerSourceFile: string,
|
2022-12-27 20:14:39 +03:00
|
|
|
frameworkPluginFactory: () => Promise<Plugin>): TestRunnerPlugin {
|
2022-05-06 22:02:07 +03:00
|
|
|
let configDir: string;
|
2023-01-24 04:44:23 +03:00
|
|
|
let config: FullConfig;
|
2022-04-25 20:40:58 +03:00
|
|
|
return {
|
2022-05-06 22:02:07 +03:00
|
|
|
name: 'playwright-vite-plugin',
|
2022-04-28 21:43:39 +03:00
|
|
|
|
2023-01-24 04:44:23 +03:00
|
|
|
setup: async (configObject: FullConfig, configDirectory: string) => {
|
|
|
|
config = configObject;
|
2022-05-06 22:02:07 +03:00
|
|
|
configDir = configDirectory;
|
2023-01-24 04:44:23 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
begin: async (suite: Suite) => {
|
2022-05-10 22:21:29 +03:00
|
|
|
const use = config.projects[0].use as CtConfig;
|
|
|
|
const port = use.ctPort || 3100;
|
2023-01-28 01:13:15 +03:00
|
|
|
const viteConfig = typeof use.ctViteConfig === 'function' ? await use.ctViteConfig() : (use.ctViteConfig || {});
|
2022-05-10 22:21:29 +03:00
|
|
|
const relativeTemplateDir = use.ctTemplateDir || 'playwright';
|
2022-05-06 22:02:07 +03:00
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
const rootDir = viteConfig.root || configDir;
|
2022-05-10 22:21:29 +03:00
|
|
|
const templateDir = path.join(rootDir, relativeTemplateDir);
|
|
|
|
const outDir = viteConfig?.build?.outDir || (use.ctCacheDir ? path.resolve(rootDir, use.ctCacheDir) : path.resolve(templateDir, '.cache'));
|
2022-05-09 19:10:47 +03:00
|
|
|
|
|
|
|
const buildInfoFile = path.join(outDir, 'metainfo.json');
|
2022-05-24 00:33:16 +03:00
|
|
|
let buildExists = false;
|
2022-05-09 19:10:47 +03:00
|
|
|
let buildInfo: BuildInfo;
|
2022-06-03 03:37:43 +03:00
|
|
|
|
|
|
|
const registerSource = await fs.promises.readFile(registerSourceFile, 'utf-8');
|
|
|
|
const registerSourceHash = calculateSha1(registerSource);
|
|
|
|
|
2022-09-16 01:24:01 +03:00
|
|
|
const { version: viteVersion } = require('vite/package.json');
|
2022-05-09 19:10:47 +03:00
|
|
|
try {
|
|
|
|
buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo;
|
2022-09-16 01:24:01 +03:00
|
|
|
assert(buildInfo.version === playwrightVersion);
|
|
|
|
assert(buildInfo.viteVersion === viteVersion);
|
2022-06-03 03:37:43 +03:00
|
|
|
assert(buildInfo.registerSourceHash === registerSourceHash);
|
2022-05-24 00:33:16 +03:00
|
|
|
buildExists = true;
|
2022-05-09 19:10:47 +03:00
|
|
|
} catch (e) {
|
|
|
|
buildInfo = {
|
2022-09-16 01:24:01 +03:00
|
|
|
version: playwrightVersion,
|
|
|
|
viteVersion,
|
2022-06-03 03:37:43 +03:00
|
|
|
registerSourceHash,
|
2022-05-09 19:10:47 +03:00
|
|
|
components: [],
|
|
|
|
tests: {},
|
|
|
|
sources: {},
|
|
|
|
};
|
2022-05-06 22:02:07 +03:00
|
|
|
}
|
2022-05-09 19:10:47 +03:00
|
|
|
|
|
|
|
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.
|
2022-05-24 00:37:56 +03:00
|
|
|
const sourcesDirty = !buildExists || hasNewComponents || await checkSources(buildInfo);
|
2022-05-09 19:10:47 +03:00
|
|
|
|
|
|
|
viteConfig.root = rootDir;
|
2022-12-29 04:04:23 +03:00
|
|
|
viteConfig.preview = { port, ...viteConfig.preview };
|
2022-05-06 22:02:07 +03:00
|
|
|
viteConfig.build = {
|
2022-05-09 19:10:47 +03:00
|
|
|
outDir
|
2022-05-06 22:02:07 +03:00
|
|
|
};
|
2022-05-25 05:43:28 +03:00
|
|
|
|
|
|
|
// React heuristic. If we see a component in a file with .js extension,
|
|
|
|
// consider it a potential JSX-in-JS scenario and enable JSX loader for all
|
|
|
|
// .js files.
|
|
|
|
if (hasJSComponents(buildInfo.components)) {
|
|
|
|
viteConfig.esbuild = {
|
|
|
|
loader: 'jsx',
|
|
|
|
include: /.*\.jsx?$/,
|
|
|
|
exclude: [],
|
|
|
|
};
|
|
|
|
viteConfig.optimizeDeps = {
|
|
|
|
esbuildOptions: {
|
|
|
|
loader: { '.js': 'jsx' },
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
2022-05-06 22:02:07 +03:00
|
|
|
const { build, preview } = require('vite');
|
2022-08-12 20:37:35 +03:00
|
|
|
// Build config unconditionally, either build or build & preview will use it.
|
|
|
|
viteConfig.plugins = viteConfig.plugins || [
|
2022-12-27 20:14:39 +03:00
|
|
|
await frameworkPluginFactory()
|
2022-08-12 20:37:35 +03:00
|
|
|
];
|
|
|
|
// But only add out own plugin when we actually build / transform.
|
|
|
|
if (sourcesDirty)
|
2022-05-10 22:21:29 +03:00
|
|
|
viteConfig.plugins.push(vitePlugin(registerSource, relativeTemplateDir, buildInfo, componentRegistry));
|
2022-08-12 20:37:35 +03:00
|
|
|
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')
|
2022-05-09 19:10:47 +03:00
|
|
|
},
|
2022-08-12 20:37:35 +03:00
|
|
|
},
|
|
|
|
sourcemap: true,
|
|
|
|
};
|
|
|
|
|
2022-10-31 22:58:07 +03:00
|
|
|
if (sourcesDirty) {
|
2022-05-09 19:10:47 +03:00
|
|
|
await build(viteConfig);
|
2022-10-31 22:58:07 +03:00
|
|
|
await fs.promises.rename(`${outDir}/${relativeTemplateDir}/index.html`, `${outDir}/index.html`);
|
|
|
|
}
|
2022-08-12 20:37:35 +03:00
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
if (hasNewTests || hasNewComponents || sourcesDirty)
|
|
|
|
await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
|
2022-08-12 20:37:35 +03:00
|
|
|
|
2022-08-08 18:54:56 +03:00
|
|
|
const previewServer = await preview(viteConfig);
|
|
|
|
stoppableServer = stoppable(previewServer.httpServer, 0);
|
2022-05-24 23:54:12 +03:00
|
|
|
const isAddressInfo = (x: any): x is AddressInfo => x?.address;
|
|
|
|
const address = previewServer.httpServer.address();
|
2022-12-29 04:04:23 +03:00
|
|
|
if (isAddressInfo(address)) {
|
|
|
|
const protocol = viteConfig.preview.https ? 'https:' : 'http:';
|
|
|
|
process.env.PLAYWRIGHT_TEST_BASE_URL = `${protocol}//localhost:${address.port}`;
|
|
|
|
}
|
2022-04-25 20:40:58 +03:00
|
|
|
},
|
|
|
|
|
2023-02-07 02:52:14 +03:00
|
|
|
end: async () => {
|
2022-08-08 18:54:56 +03:00
|
|
|
await new Promise(f => stoppableServer.stop(f));
|
2022-04-25 20:40:58 +03:00
|
|
|
},
|
2022-04-22 03:30:17 +03:00
|
|
|
};
|
|
|
|
}
|
2022-05-06 00:26:56 +03:00
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
type BuildInfo = {
|
2022-09-16 01:24:01 +03:00
|
|
|
version: string,
|
|
|
|
viteVersion: string,
|
2022-06-03 03:37:43 +03:00
|
|
|
registerSourceHash: string,
|
2022-05-09 19:10:47 +03:00
|
|
|
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;
|
2022-05-06 22:02:07 +03:00
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-05-10 22:21:29 +03:00
|
|
|
function vitePlugin(registerSource: string, relativeTemplateDir: string, buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Plugin {
|
2022-05-09 19:10:47 +03:00
|
|
|
buildInfo.sources = {};
|
2022-05-06 22:02:07 +03:00
|
|
|
return {
|
|
|
|
name: 'playwright:component-index',
|
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
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.
|
|
|
|
}
|
2022-05-06 22:02:07 +03:00
|
|
|
}
|
|
|
|
|
2022-05-25 05:43:28 +03:00
|
|
|
// Vite React plugin will do this for .jsx files, but not .js files.
|
2022-07-06 23:44:06 +03:00
|
|
|
if (id.endsWith('.js') && content.includes('React.createElement') && !content.match(importReactRE) && !content.match(compiledReactRE)) {
|
2022-05-25 05:43:28 +03:00
|
|
|
const code = `import React from 'react';\n${content}`;
|
|
|
|
return { code, map: { mappings: '' } };
|
|
|
|
}
|
|
|
|
|
2022-11-11 20:52:39 +03:00
|
|
|
const indexTs = path.join(relativeTemplateDir, 'index.ts');
|
|
|
|
const indexTsx = path.join(relativeTemplateDir, 'index.tsx');
|
|
|
|
const indexJs = path.join(relativeTemplateDir, 'index.js');
|
|
|
|
const idResolved = path.resolve(id);
|
|
|
|
if (!idResolved.endsWith(indexTs) && !idResolved.endsWith(indexTsx) && !idResolved.endsWith(indexJs))
|
2022-05-06 22:02:07 +03:00
|
|
|
return;
|
|
|
|
|
|
|
|
const folder = path.dirname(id);
|
|
|
|
const lines = [content, ''];
|
|
|
|
lines.push(registerSource);
|
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
for (const [alias, value] of componentRegistry) {
|
2022-05-06 22:02:07 +03:00
|
|
|
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}';`);
|
|
|
|
else
|
|
|
|
lines.push(`import ${alias} from '${importPath}';`);
|
|
|
|
}
|
|
|
|
|
2022-05-09 19:10:47 +03:00
|
|
|
lines.push(`register({ ${[...componentRegistry.keys()].join(',\n ')} });`);
|
2022-05-07 04:29:49 +03:00
|
|
|
return {
|
|
|
|
code: lines.join('\n'),
|
|
|
|
map: { mappings: '' }
|
|
|
|
};
|
2022-05-06 22:02:07 +03:00
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
2022-05-25 05:43:28 +03:00
|
|
|
|
|
|
|
function hasJSComponents(components: ComponentInfo[]): boolean {
|
|
|
|
for (const component of components) {
|
|
|
|
const extname = path.extname(component.importPath);
|
|
|
|
if (extname === '.js' || !extname && fs.existsSync(component.importPath + '.js'))
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|