285 lines
7.6 KiB
TypeScript
285 lines
7.6 KiB
TypeScript
import { existsSync, readdirSync } from 'fs';
|
|
import { dirname, join, relative } from 'path';
|
|
|
|
import {
|
|
CreateDependencies,
|
|
CreateNodes,
|
|
CreateNodesContext,
|
|
detectPackageManager,
|
|
joinPathFragments,
|
|
readJsonFile,
|
|
TargetConfiguration,
|
|
writeJsonFile,
|
|
} from '@nx/devkit';
|
|
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
|
|
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
|
|
|
|
import type { PlaywrightTestConfig } from '@playwright/test';
|
|
import { getFilesInDirectoryUsingContext } from 'nx/src/utils/workspace-context';
|
|
import minimatch = require('minimatch');
|
|
import { loadPlaywrightConfig } from '../utils/load-config-file';
|
|
import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory';
|
|
import { getLockFileName } from '@nx/js';
|
|
|
|
export interface PlaywrightPluginOptions {
|
|
targetName?: string;
|
|
ciTargetName?: string;
|
|
}
|
|
|
|
interface NormalizedOptions {
|
|
targetName: string;
|
|
ciTargetName?: string;
|
|
}
|
|
|
|
const cachePath = join(projectGraphCacheDirectory, 'playwright.hash');
|
|
|
|
const targetsCache = existsSync(cachePath) ? readTargetsCache() : {};
|
|
|
|
const calculatedTargets: Record<
|
|
string,
|
|
Record<string, TargetConfiguration>
|
|
> = {};
|
|
|
|
function readTargetsCache(): Record<
|
|
string,
|
|
Record<string, TargetConfiguration>
|
|
> {
|
|
return readJsonFile(cachePath);
|
|
}
|
|
|
|
function writeTargetsToCache(
|
|
targets: Record<string, Record<string, TargetConfiguration>>
|
|
) {
|
|
writeJsonFile(cachePath, targets);
|
|
}
|
|
|
|
export const createDependencies: CreateDependencies = () => {
|
|
writeTargetsToCache(calculatedTargets);
|
|
return [];
|
|
};
|
|
|
|
export const createNodes: CreateNodes<PlaywrightPluginOptions> = [
|
|
'**/playwright.config.{js,ts,cjs,cts,mjs,mts}',
|
|
async (configFilePath, options, context) => {
|
|
const projectRoot = dirname(configFilePath);
|
|
|
|
// Do not create a project if package.json and project.json isn't there.
|
|
const siblingFiles = readdirSync(projectRoot);
|
|
if (
|
|
!siblingFiles.includes('package.json') &&
|
|
!siblingFiles.includes('project.json')
|
|
) {
|
|
return {};
|
|
}
|
|
|
|
const normalizedOptions = normalizeOptions(options);
|
|
|
|
const hash = calculateHashForCreateNodes(projectRoot, options, context, [
|
|
getLockFileName(detectPackageManager(context.workspaceRoot)),
|
|
]);
|
|
|
|
const targets =
|
|
targetsCache[hash] ??
|
|
(await buildPlaywrightTargets(
|
|
configFilePath,
|
|
projectRoot,
|
|
normalizedOptions,
|
|
context
|
|
));
|
|
|
|
calculatedTargets[hash] = targets;
|
|
|
|
return {
|
|
projects: {
|
|
[projectRoot]: {
|
|
root: projectRoot,
|
|
targets,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
];
|
|
|
|
async function buildPlaywrightTargets(
|
|
configFilePath: string,
|
|
projectRoot: string,
|
|
options: NormalizedOptions,
|
|
context: CreateNodesContext
|
|
) {
|
|
const playwrightConfig: PlaywrightTestConfig = await loadPlaywrightConfig(
|
|
join(context.workspaceRoot, configFilePath)
|
|
);
|
|
|
|
const namedInputs = getNamedInputs(projectRoot, context);
|
|
|
|
const targets: Record<string, TargetConfiguration<unknown>> = {};
|
|
|
|
const baseTargetConfig: TargetConfiguration = {
|
|
command: 'playwright test',
|
|
options: {
|
|
cwd: '{projectRoot}',
|
|
},
|
|
};
|
|
|
|
targets[options.targetName] = {
|
|
...baseTargetConfig,
|
|
cache: true,
|
|
inputs:
|
|
'production' in namedInputs
|
|
? ['default', '^production']
|
|
: ['default', '^default'],
|
|
outputs: getOutputs(projectRoot, playwrightConfig),
|
|
};
|
|
|
|
if (options.ciTargetName) {
|
|
const ciBaseTargetConfig: TargetConfiguration = {
|
|
...baseTargetConfig,
|
|
cache: true,
|
|
inputs:
|
|
'production' in namedInputs
|
|
? ['default', '^production']
|
|
: ['default', '^default'],
|
|
outputs: getOutputs(projectRoot, playwrightConfig),
|
|
};
|
|
|
|
const testDir = playwrightConfig.testDir
|
|
? joinPathFragments(projectRoot, playwrightConfig.testDir)
|
|
: projectRoot;
|
|
|
|
// Playwright defaults to the following pattern.
|
|
playwrightConfig.testMatch ??= '**/*.@(spec|test).?(c|m)[jt]s?(x)';
|
|
|
|
const dependsOn: TargetConfiguration['dependsOn'] = [];
|
|
forEachTestFile(
|
|
(testFile) => {
|
|
const relativeToProjectRoot = relative(projectRoot, testFile);
|
|
const targetName = `${options.ciTargetName}--${relativeToProjectRoot}`;
|
|
targets[targetName] = {
|
|
...ciBaseTargetConfig,
|
|
command: `${baseTargetConfig.command} ${relativeToProjectRoot}`,
|
|
};
|
|
dependsOn.push({
|
|
target: targetName,
|
|
projects: 'self',
|
|
params: 'forward',
|
|
});
|
|
},
|
|
{
|
|
context,
|
|
path: testDir,
|
|
config: playwrightConfig,
|
|
}
|
|
);
|
|
|
|
targets[options.ciTargetName] ??= {};
|
|
|
|
targets[options.ciTargetName] = {
|
|
executor: 'nx:noop',
|
|
cache: ciBaseTargetConfig.cache,
|
|
inputs: ciBaseTargetConfig.inputs,
|
|
outputs: ciBaseTargetConfig.outputs,
|
|
dependsOn,
|
|
};
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
async function forEachTestFile(
|
|
cb: (path: string) => void,
|
|
opts: {
|
|
context: CreateNodesContext;
|
|
path: string;
|
|
config: PlaywrightTestConfig;
|
|
}
|
|
) {
|
|
const files = getFilesInDirectoryUsingContext(
|
|
opts.context.workspaceRoot,
|
|
opts.path
|
|
);
|
|
const matcher = createMatcher(opts.config.testMatch);
|
|
const ignoredMatcher = opts.config.testIgnore
|
|
? createMatcher(opts.config.testIgnore)
|
|
: () => false;
|
|
for (const file of files) {
|
|
if (matcher(file) && !ignoredMatcher(file)) {
|
|
cb(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createMatcher(pattern: string | RegExp | Array<string | RegExp>) {
|
|
if (Array.isArray(pattern)) {
|
|
const matchers = pattern.map((p) => createMatcher(p));
|
|
return (path: string) => matchers.some((m) => m(path));
|
|
} else if (pattern instanceof RegExp) {
|
|
return (path: string) => pattern.test(path);
|
|
} else {
|
|
return (path: string) => {
|
|
try {
|
|
return minimatch(path, pattern);
|
|
} catch (e) {
|
|
throw new Error(`Error matching ${path} with ${pattern}: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
function getOutputs(
|
|
projectRoot: string,
|
|
playwrightConfig: PlaywrightTestConfig
|
|
): string[] {
|
|
function getOutput(path: string): string {
|
|
if (path.startsWith('..')) {
|
|
return join('{workspaceRoot}', join(projectRoot, path));
|
|
} else {
|
|
return join('{projectRoot}', path);
|
|
}
|
|
}
|
|
|
|
const outputs = [];
|
|
|
|
const { reporter, outputDir } = playwrightConfig;
|
|
|
|
if (reporter) {
|
|
const DEFAULT_REPORTER_OUTPUT = getOutput('playwright-report');
|
|
if (reporter === 'html' || reporter === 'json') {
|
|
// Reporter is a string, so it uses the default output directory.
|
|
outputs.push(DEFAULT_REPORTER_OUTPUT);
|
|
} else if (Array.isArray(reporter)) {
|
|
for (const r of reporter) {
|
|
const [, opts] = r;
|
|
// There are a few different ways to specify an output file or directory
|
|
// depending on the reporter. This is a best effort to find the output.
|
|
if (!opts) {
|
|
outputs.push(DEFAULT_REPORTER_OUTPUT);
|
|
} else if (opts.outputFile) {
|
|
outputs.push(getOutput(opts.outputFile));
|
|
} else if (opts.outputDir) {
|
|
outputs.push(getOutput(opts.outputDir));
|
|
} else if (opts.outputFolder) {
|
|
outputs.push(getOutput(opts.outputFolder));
|
|
} else {
|
|
outputs.push(DEFAULT_REPORTER_OUTPUT);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (outputDir) {
|
|
outputs.push(getOutput(outputDir));
|
|
} else {
|
|
outputs.push(getOutput('./test-results'));
|
|
}
|
|
|
|
return outputs;
|
|
}
|
|
|
|
function normalizeOptions(options: PlaywrightPluginOptions): NormalizedOptions {
|
|
return {
|
|
...options,
|
|
targetName: options.targetName ?? 'e2e',
|
|
ciTargetName: options.ciTargetName ?? 'e2e-ci',
|
|
};
|
|
}
|