203 lines
7.5 KiB
TypeScript

/**
* 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.
*/
import fs from 'fs';
import path from 'path';
import { getUserData } from 'playwright/lib/transform/compilationCache';
import { resolveHook } from 'playwright/lib/transform/transform';
import { debug } from 'playwright-core/lib/utilsBundle';
import type { ImportInfo } from './tsxTransform';
import type { PlaywrightTestConfig as BasePlaywrightTestConfig } from 'playwright/types/test';
import type { FullConfig } from 'playwright/types/testReporter';
import type { InlineConfig, Plugin, TransformResult, UserConfig } from 'vite';
const log = debug('pw:vite');
export type CtConfig = BasePlaywrightTestConfig['use'] & {
ctPort?: number;
ctTemplateDir?: string;
ctCacheDir?: string;
ctViteConfig?: InlineConfig | (() => Promise<InlineConfig>);
};
export type ComponentRegistry = Map<string, ImportInfo>;
export type ComponentDirs = {
configDir: string;
outDir: string;
templateDir: string;
};
export async function resolveDirs(configDir: string, config: FullConfig): Promise<ComponentDirs | null> {
const use = config.projects[0].use as CtConfig;
// FIXME: use build plugin to determine html location to resolve this.
// TemplateDir must be relative, otherwise we can't move the final index.html into its target location post-build.
// This regressed in https://github.com/microsoft/playwright/pull/26526
const relativeTemplateDir = use.ctTemplateDir || 'playwright';
const templateDir = await fs.promises.realpath(path.normalize(path.join(configDir, relativeTemplateDir))).catch(() => undefined);
if (!templateDir)
return null;
const outDir = use.ctCacheDir ? path.resolve(configDir, use.ctCacheDir) : path.resolve(templateDir, '.cache');
return {
configDir,
outDir,
templateDir
};
}
export function resolveEndpoint(config: FullConfig) {
const use = config.projects[0].use as CtConfig;
const baseURL = new URL(use.baseURL || 'http://localhost');
return {
https: baseURL.protocol.startsWith('https:') ? {} : undefined,
host: baseURL.hostname,
port: use.ctPort || Number(baseURL.port) || 3100
};
}
export async function createConfig(dirs: ComponentDirs, config: FullConfig, frameworkPluginFactory: (() => Promise<Plugin>) | undefined, supportJsxInJs: boolean) {
// We are going to have 3 config files:
// - the defaults that user config overrides (baseConfig)
// - the user config (userConfig)
// - frameworks overrides (frameworkOverrides);
const endpoint = resolveEndpoint(config);
const use = config.projects[0].use as CtConfig;
// Compose base config from the playwright config only.
const baseConfig: InlineConfig = {
root: dirs.templateDir,
configFile: false,
publicDir: path.join(dirs.configDir, 'public'),
define: {
__VUE_PROD_DEVTOOLS__: true,
},
css: {
devSourcemap: true,
},
build: {
outDir: dirs.outDir
},
preview: endpoint,
server: endpoint,
// Vite preview server will otherwise always return the index.html with 200.
appType: 'mpa',
};
// Vite 5 refuses to support CJS.
const { mergeConfig } = await import('vite');
// Apply user config on top of the base config. This could have changed root and build.outDir.
const userConfig = typeof use.ctViteConfig === 'function' ? await use.ctViteConfig() : (use.ctViteConfig || {});
const baseAndUserConfig = mergeConfig(baseConfig, userConfig);
const frameworkOverrides: UserConfig = { plugins: [] };
// 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 (supportJsxInJs) {
log('jsx-in-js detected');
frameworkOverrides.esbuild = {
loader: 'jsx',
include: /.*\.jsx?$/,
exclude: [],
};
frameworkOverrides.optimizeDeps = {
esbuildOptions: {
loader: { '.js': 'jsx' },
}
};
}
frameworkOverrides.build = {
target: 'esnext',
minify: false,
rollupOptions: {
treeshake: false,
input: {
index: path.join(dirs.templateDir, 'index.html')
},
},
sourcemap: true,
};
// We assume that any non-empty plugin list includes `vite-react` or similar.
if (frameworkPluginFactory && !baseAndUserConfig.plugins?.length)
frameworkOverrides.plugins = [await frameworkPluginFactory()];
return mergeConfig(baseAndUserConfig, frameworkOverrides);
}
export 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);
if (componentsByImportingFile)
componentsByImportingFile.set(file, importList.map(i => resolveHook(i.filename, i.importSource)).filter(Boolean) as string[]);
}
}
export function hasJSComponents(components: ImportInfo[]): boolean {
for (const component of components) {
const importPath = resolveHook(component.filename, component.importSource);
const extname = importPath ? path.extname(importPath) : '';
if (extname === '.js' || (importPath && !extname && fs.existsSync(importPath + '.js')))
return true;
}
return false;
}
const importReactRE = /(^|\n|;)import\s+(\*\s+as\s+)?React(,|\s+)/;
const compiledReactRE = /(const|var)\s+React\s*=/;
export function transformIndexFile(id: string, content: string, templateDir: string, registerSource: string, importInfos: Map<string, ImportInfo>): TransformResult | null {
// Vite React plugin will do this for .jsx files, but not .js files.
if (id.endsWith('.js') && content.includes('React.createElement') && !content.match(importReactRE) && !content.match(compiledReactRE)) {
const code = `import React from 'react';\n${content}`;
return { code, map: { mappings: '' } };
}
const indexTs = path.join(templateDir, 'index.ts');
const indexTsx = path.join(templateDir, 'index.tsx');
const indexJs = path.join(templateDir, 'index.js');
const indexJsx = path.join(templateDir, 'index.jsx');
const idResolved = path.resolve(id);
if (!idResolved.endsWith(indexTs) && !idResolved.endsWith(indexTsx) && !idResolved.endsWith(indexJs) && !idResolved.endsWith(indexJsx))
return null;
const lines = [content, ''];
lines.push(registerSource);
for (const value of importInfos.values()) {
const importPath = resolveHook(value.filename, value.importSource) || value.importSource;
lines.push(`const ${value.id} = () => import('${importPath?.replaceAll(path.sep, '/')}').then((mod) => mod.${value.remoteName || 'default'});`);
}
lines.push(`__pwRegistry.initialize({ ${[...importInfos.keys()].join(',\n ')} });`);
return {
code: lines.join('\n'),
map: { mappings: '' }
};
}
export function frameworkConfig(config: FullConfig): { registerSourceFile: string, frameworkPluginFactory?: () => Promise<Plugin> } {
return (config as any)['@playwright/experimental-ct-core'];
}