## Current Behavior The `@nx/react` `host` and `remote` generators currently use executors to support Module Federation ## Expected Behavior When `bundler=rspack` use Crystal Module Federation with no executors for Module Federation ## Related Issues #30391
481 lines
13 KiB
TypeScript
481 lines
13 KiB
TypeScript
import { existsSync, readdirSync } from 'node:fs';
|
|
import { dirname, join, parse, relative, resolve } from 'node:path';
|
|
|
|
import {
|
|
CreateNodes,
|
|
CreateNodesContext,
|
|
createNodesFromFiles,
|
|
CreateNodesV2,
|
|
detectPackageManager,
|
|
getPackageManagerCommand,
|
|
joinPathFragments,
|
|
logger,
|
|
normalizePath,
|
|
ProjectConfiguration,
|
|
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 } from 'minimatch';
|
|
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
|
|
import { getLockFileName } from '@nx/js';
|
|
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
|
|
import { hashObject } from 'nx/src/hasher/file-hasher';
|
|
|
|
const pmc = getPackageManagerCommand();
|
|
|
|
export interface PlaywrightPluginOptions {
|
|
targetName?: string;
|
|
ciTargetName?: string;
|
|
}
|
|
|
|
interface NormalizedOptions {
|
|
targetName: string;
|
|
ciTargetName?: string;
|
|
}
|
|
|
|
type PlaywrightTargets = Pick<ProjectConfiguration, 'targets' | 'metadata'>;
|
|
|
|
function readTargetsCache(
|
|
cachePath: string
|
|
): Record<string, PlaywrightTargets> {
|
|
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
|
|
}
|
|
|
|
function writeTargetsToCache(
|
|
cachePath: string,
|
|
results: Record<string, PlaywrightTargets>
|
|
) {
|
|
writeJsonFile(cachePath, results);
|
|
}
|
|
|
|
const playwrightConfigGlob = '**/playwright.config.{js,ts,cjs,cts,mjs,mts}';
|
|
export const createNodesV2: CreateNodesV2<PlaywrightPluginOptions> = [
|
|
playwrightConfigGlob,
|
|
async (configFilePaths, options, context) => {
|
|
const optionsHash = hashObject(options);
|
|
const cachePath = join(
|
|
workspaceDataDirectory,
|
|
`playwright-${optionsHash}.hash`
|
|
);
|
|
const targetsCache = readTargetsCache(cachePath);
|
|
try {
|
|
return await createNodesFromFiles(
|
|
(configFile, options, context) =>
|
|
createNodesInternal(configFile, options, context, targetsCache),
|
|
configFilePaths,
|
|
options,
|
|
context
|
|
);
|
|
} finally {
|
|
writeTargetsToCache(cachePath, targetsCache);
|
|
}
|
|
},
|
|
];
|
|
|
|
/**
|
|
* @deprecated This is replaced with {@link createNodesV2}. Update your plugin to export its own `createNodesV2` function that wraps this one instead.
|
|
* This function will change to the v2 function in Nx 20.
|
|
*/
|
|
export const createNodes: CreateNodes<PlaywrightPluginOptions> = [
|
|
playwrightConfigGlob,
|
|
async (configFile, options, context) => {
|
|
logger.warn(
|
|
'`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.'
|
|
);
|
|
return createNodesInternal(configFile, options, context, {});
|
|
},
|
|
];
|
|
|
|
async function createNodesInternal(
|
|
configFilePath: string,
|
|
options: PlaywrightPluginOptions,
|
|
context: CreateNodesContext,
|
|
targetsCache: Record<string, PlaywrightTargets>
|
|
) {
|
|
const projectRoot = dirname(configFilePath);
|
|
|
|
// Do not create a project if package.json and project.json isn't there.
|
|
const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot));
|
|
if (
|
|
!siblingFiles.includes('package.json') &&
|
|
!siblingFiles.includes('project.json')
|
|
) {
|
|
return {};
|
|
}
|
|
|
|
const normalizedOptions = normalizeOptions(options);
|
|
|
|
const hash = await calculateHashForCreateNodes(
|
|
projectRoot,
|
|
normalizedOptions,
|
|
context,
|
|
[getLockFileName(detectPackageManager(context.workspaceRoot))]
|
|
);
|
|
|
|
targetsCache[hash] ??= await buildPlaywrightTargets(
|
|
configFilePath,
|
|
projectRoot,
|
|
normalizedOptions,
|
|
context
|
|
);
|
|
const { targets, metadata } = targetsCache[hash];
|
|
|
|
return {
|
|
projects: {
|
|
[projectRoot]: {
|
|
root: projectRoot,
|
|
targets,
|
|
metadata,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildPlaywrightTargets(
|
|
configFilePath: string,
|
|
projectRoot: string,
|
|
options: NormalizedOptions,
|
|
context: CreateNodesContext
|
|
): Promise<PlaywrightTargets> {
|
|
// Playwright forbids importing the `@playwright/test` module twice. This would affect running the tests,
|
|
// but we're just reading the config so let's delete the variable they are using to detect this.
|
|
// See: https://github.com/microsoft/playwright/pull/11218/files
|
|
delete (process as any)['__pw_initiator__'];
|
|
|
|
const playwrightConfig = await loadConfigFile<PlaywrightTestConfig>(
|
|
join(context.workspaceRoot, configFilePath)
|
|
);
|
|
|
|
const namedInputs = getNamedInputs(projectRoot, context);
|
|
|
|
const targets: ProjectConfiguration['targets'] = {};
|
|
let metadata: ProjectConfiguration['metadata'];
|
|
|
|
const testOutput = getTestOutput(playwrightConfig);
|
|
const reporterOutputs = getReporterOutputs(playwrightConfig);
|
|
const baseTargetConfig: TargetConfiguration = {
|
|
command: 'playwright test',
|
|
options: {
|
|
cwd: '{projectRoot}',
|
|
},
|
|
parallelism: false,
|
|
metadata: {
|
|
technologies: ['playwright'],
|
|
description: 'Runs Playwright Tests',
|
|
help: {
|
|
command: `${pmc.exec} playwright test --help`,
|
|
example: {
|
|
options: {
|
|
workers: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
targets[options.targetName] = {
|
|
...baseTargetConfig,
|
|
cache: true,
|
|
inputs: [
|
|
...('production' in namedInputs
|
|
? ['default', '^production']
|
|
: ['default', '^default']),
|
|
{ externalDependencies: ['@playwright/test'] },
|
|
],
|
|
outputs: getTargetOutputs(
|
|
testOutput,
|
|
reporterOutputs,
|
|
context.workspaceRoot,
|
|
projectRoot
|
|
),
|
|
};
|
|
|
|
if (options.ciTargetName) {
|
|
const ciBaseTargetConfig: TargetConfiguration = {
|
|
...baseTargetConfig,
|
|
cache: true,
|
|
inputs: [
|
|
...('production' in namedInputs
|
|
? ['default', '^production']
|
|
: ['default', '^default']),
|
|
{ externalDependencies: ['@playwright/test'] },
|
|
],
|
|
outputs: getTargetOutputs(
|
|
testOutput,
|
|
reporterOutputs,
|
|
context.workspaceRoot,
|
|
projectRoot
|
|
),
|
|
};
|
|
|
|
const groupName = 'E2E (CI)';
|
|
metadata = { targetGroups: { [groupName]: [] } };
|
|
const ciTargetGroup = metadata.targetGroups[groupName];
|
|
|
|
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'] = [];
|
|
|
|
await forEachTestFile(
|
|
(testFile) => {
|
|
const outputSubfolder = relative(projectRoot, testFile)
|
|
.replace(/[\/\\]/g, '-')
|
|
.replace(/\./g, '-');
|
|
const relativeSpecFilePath = normalizePath(
|
|
relative(projectRoot, testFile)
|
|
);
|
|
const targetName = `${options.ciTargetName}--${relativeSpecFilePath}`;
|
|
ciTargetGroup.push(targetName);
|
|
targets[targetName] = {
|
|
...ciBaseTargetConfig,
|
|
options: {
|
|
...ciBaseTargetConfig.options,
|
|
env: getOutputEnvVars(reporterOutputs, outputSubfolder),
|
|
},
|
|
outputs: getTargetOutputs(
|
|
testOutput,
|
|
reporterOutputs,
|
|
context.workspaceRoot,
|
|
projectRoot,
|
|
outputSubfolder
|
|
),
|
|
command: `${
|
|
baseTargetConfig.command
|
|
} ${relativeSpecFilePath} --output=${join(
|
|
testOutput,
|
|
outputSubfolder
|
|
)}`,
|
|
metadata: {
|
|
technologies: ['playwright'],
|
|
description: `Runs Playwright Tests in ${relativeSpecFilePath} in CI`,
|
|
help: {
|
|
command: `${pmc.exec} playwright test --help`,
|
|
example: {
|
|
options: {
|
|
workers: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
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,
|
|
parallelism: false,
|
|
metadata: {
|
|
technologies: ['playwright'],
|
|
description: 'Runs Playwright Tests in CI',
|
|
nonAtomizedTarget: options.targetName,
|
|
help: {
|
|
command: `${pmc.exec} playwright test --help`,
|
|
example: {
|
|
options: {
|
|
workers: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
ciTargetGroup.push(options.ciTargetName);
|
|
}
|
|
|
|
return { targets, metadata };
|
|
}
|
|
|
|
async function forEachTestFile(
|
|
cb: (path: string) => void,
|
|
opts: {
|
|
context: CreateNodesContext;
|
|
path: string;
|
|
config: PlaywrightTestConfig;
|
|
}
|
|
) {
|
|
const files = await 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 normalizeOptions(options: PlaywrightPluginOptions): NormalizedOptions {
|
|
return {
|
|
...options,
|
|
targetName: options?.targetName ?? 'e2e',
|
|
ciTargetName: options?.ciTargetName ?? 'e2e-ci',
|
|
};
|
|
}
|
|
|
|
function getTestOutput(playwrightConfig: PlaywrightTestConfig): string {
|
|
const { outputDir } = playwrightConfig;
|
|
if (outputDir) {
|
|
return outputDir;
|
|
} else {
|
|
return './test-results';
|
|
}
|
|
}
|
|
|
|
function getReporterOutputs(
|
|
playwrightConfig: PlaywrightTestConfig
|
|
): Array<[string, string]> {
|
|
const outputs: Array<[string, string]> = [];
|
|
|
|
const { reporter } = playwrightConfig;
|
|
|
|
if (reporter) {
|
|
const DEFAULT_REPORTER_OUTPUT = 'playwright-report';
|
|
if (reporter === 'html') {
|
|
outputs.push([reporter, DEFAULT_REPORTER_OUTPUT]);
|
|
} else if (reporter === 'json') {
|
|
outputs.push([reporter, DEFAULT_REPORTER_OUTPUT]);
|
|
} else if (Array.isArray(reporter)) {
|
|
for (const r of reporter) {
|
|
const [reporter, 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?.outputFile) {
|
|
outputs.push([reporter, opts.outputFile]);
|
|
} else if (opts?.outputDir) {
|
|
outputs.push([reporter, opts.outputDir]);
|
|
} else if (opts?.outputFolder) {
|
|
outputs.push([reporter, opts.outputFolder]);
|
|
} else {
|
|
outputs.push([reporter, DEFAULT_REPORTER_OUTPUT]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return outputs;
|
|
}
|
|
|
|
function getTargetOutputs(
|
|
testOutput: string,
|
|
reporterOutputs: Array<[string, string]>,
|
|
workspaceRoot: string,
|
|
projectRoot: string,
|
|
subFolder?: string
|
|
): string[] {
|
|
const outputs = new Set<string>();
|
|
outputs.add(
|
|
normalizeOutput(
|
|
addSubfolderToOutput(testOutput, subFolder),
|
|
workspaceRoot,
|
|
projectRoot
|
|
)
|
|
);
|
|
for (const [, output] of reporterOutputs) {
|
|
outputs.add(
|
|
normalizeOutput(
|
|
addSubfolderToOutput(output, subFolder),
|
|
workspaceRoot,
|
|
projectRoot
|
|
)
|
|
);
|
|
}
|
|
return Array.from(outputs);
|
|
}
|
|
|
|
function addSubfolderToOutput(output: string, subfolder?: string): string {
|
|
if (!subfolder) return output;
|
|
const parts = parse(output);
|
|
if (parts.ext !== '') {
|
|
return join(parts.dir, subfolder, parts.base);
|
|
}
|
|
return join(output, subfolder);
|
|
}
|
|
|
|
function normalizeOutput(
|
|
path: string,
|
|
workspaceRoot: string,
|
|
projectRoot: string
|
|
): string {
|
|
const fullProjectRoot = resolve(workspaceRoot, projectRoot);
|
|
const fullPath = resolve(fullProjectRoot, path);
|
|
const pathRelativeToProjectRoot = normalizePath(
|
|
relative(fullProjectRoot, fullPath)
|
|
);
|
|
if (pathRelativeToProjectRoot.startsWith('..')) {
|
|
return joinPathFragments(
|
|
'{workspaceRoot}',
|
|
relative(workspaceRoot, fullPath)
|
|
);
|
|
}
|
|
return joinPathFragments('{projectRoot}', pathRelativeToProjectRoot);
|
|
}
|
|
|
|
function getOutputEnvVars(
|
|
reporterOutputs: Array<[string, string]>,
|
|
outputSubfolder: string
|
|
): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
for (let [reporter, output] of reporterOutputs) {
|
|
if (outputSubfolder) {
|
|
const isFile = parse(output).ext !== '';
|
|
const envVarName = `PLAYWRIGHT_${reporter.toUpperCase()}_OUTPUT_${
|
|
isFile ? 'FILE' : 'DIR'
|
|
}`;
|
|
env[envVarName] = addSubfolderToOutput(output, outputSubfolder);
|
|
// Also set PLAYWRIGHT_HTML_REPORT for Playwright prior to 1.45.0.
|
|
// HTML prior to this version did not follow the pattern of "PLAYWRIGHT_<REPORTER>_OUTPUT_<FILE|DIR>".
|
|
if (reporter === 'html') {
|
|
env['PLAYWRIGHT_HTML_REPORT'] = env[envVarName];
|
|
}
|
|
}
|
|
}
|
|
return env;
|
|
}
|