chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
import {
|
||||
output,
|
||||
PackageManager,
|
||||
ProjectConfiguration,
|
||||
TargetConfiguration,
|
||||
} from '@nx/devkit';
|
||||
import { ChildProcess, exec, execSync, ExecSyncOptions } from 'child_process';
|
||||
import { existsSync } from 'fs-extra';
|
||||
import * as isCI from 'is-ci';
|
||||
import { join } from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import { gte } from 'semver';
|
||||
import { packageInstall, tmpProjPath } from './create-project-utils';
|
||||
import {
|
||||
ensureCypressInstallation,
|
||||
ensurePlaywrightBrowsersInstallation,
|
||||
} from './ensure-browser-installation';
|
||||
import { fileExists, readJson, updateJson } from './file-utils';
|
||||
import {
|
||||
detectPackageManager,
|
||||
getNpmMajorVersion,
|
||||
getPnpmVersion,
|
||||
getPublishedVersion,
|
||||
getStrippedEnvironmentVariables,
|
||||
getYarnMajorVersion,
|
||||
isVerboseE2ERun,
|
||||
} from './get-env-info';
|
||||
import { logError, logInfo } from './log-utils';
|
||||
|
||||
export interface RunCmdOpts {
|
||||
silenceError?: boolean;
|
||||
env?: Record<string, string | undefined>;
|
||||
cwd?: string;
|
||||
silent?: boolean;
|
||||
verbose?: boolean;
|
||||
redirectStderr?: boolean;
|
||||
timeout?: number;
|
||||
/** Override daemon mode for this call. Defaults to `true`; set `false` to exercise the non-daemon path. */
|
||||
daemon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps maxWorkers in CI for node/web/jest builds so they don't spawn ~34 workers.
|
||||
*/
|
||||
export function setMaxWorkers(projectJsonPath: string) {
|
||||
if (isCI) {
|
||||
updateJson<ProjectConfiguration>(projectJsonPath, (project) => {
|
||||
const { build } = project.targets as {
|
||||
[targetName: string]: TargetConfiguration<any>;
|
||||
};
|
||||
|
||||
if (!build) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const executor = build.executor as string;
|
||||
if (
|
||||
executor.startsWith('@nx/node') ||
|
||||
executor.startsWith('@nx/web') ||
|
||||
executor.startsWith('@nx/jest')
|
||||
) {
|
||||
build.options.maxWorkers = 4;
|
||||
}
|
||||
|
||||
return project;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
command: string,
|
||||
options?: Partial<ExecSyncOptions> & { failOnError?: boolean }
|
||||
): string {
|
||||
const {
|
||||
failOnError,
|
||||
env: optionsEnv,
|
||||
...childProcessOptions
|
||||
} = options ?? {};
|
||||
try {
|
||||
const r = execSync(command, {
|
||||
cwd: tmpProjPath(),
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
// Use new versioning by default in e2e tests
|
||||
NX_INTERNAL_USE_LEGACY_VERSIONING: 'false',
|
||||
...getStrippedEnvironmentVariables(),
|
||||
...optionsEnv,
|
||||
FORCE_COLOR: 'false',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
...childProcessOptions,
|
||||
});
|
||||
|
||||
if (isVerboseE2ERun()) {
|
||||
output.log({
|
||||
title: `Command: ${command}`,
|
||||
bodyLines: [r as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return stripVTControlCharacters(r as string);
|
||||
} catch (e) {
|
||||
// Intentional: some commands (e.g. `npm ls`) exit non-zero but still produce useful output.
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
if (!failOnError && (e.stdout || e.stderr)) {
|
||||
return stripVTControlCharacters(e.stdout + e.stderr);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPackageManagerCommand({
|
||||
path = tmpProjPath(),
|
||||
packageManager = detectPackageManager(path),
|
||||
} = {}): {
|
||||
createWorkspace: string;
|
||||
run: (script: string, args: string) => string;
|
||||
runNx: string;
|
||||
runNxSilent: string;
|
||||
runUninstalledPackage: string;
|
||||
install: string;
|
||||
ciInstall: string;
|
||||
addProd: string;
|
||||
addDev: string;
|
||||
list: string;
|
||||
runLerna: string;
|
||||
exec: string;
|
||||
} {
|
||||
const npmMajorVersion = getNpmMajorVersion();
|
||||
const yarnMajorVersion = getYarnMajorVersion(path);
|
||||
const pnpmVersion = getPnpmVersion();
|
||||
const publishedVersion = getPublishedVersion();
|
||||
const isYarnWorkspace = fileExists(join(path, 'package.json'))
|
||||
? readJson('package.json').workspaces
|
||||
: false;
|
||||
const isPnpmWorkspace = existsSync(join(path, 'pnpm-workspace.yaml'));
|
||||
|
||||
return {
|
||||
npm: {
|
||||
createWorkspace: `npx ${
|
||||
npmMajorVersion && +npmMajorVersion >= 7 ? '--yes' : ''
|
||||
} create-nx-workspace@${publishedVersion}`,
|
||||
run: (script: string, args: string) => `npm run ${script} -- ${args}`,
|
||||
runNx: `npx nx`,
|
||||
runNxSilent: `npx nx`,
|
||||
runUninstalledPackage: `npx --yes`,
|
||||
install: 'npm install',
|
||||
ciInstall: 'npm ci',
|
||||
addProd: `npm install`,
|
||||
addDev: `npm install -D`,
|
||||
list: 'npm ls --depth 10',
|
||||
runLerna: `npx lerna`,
|
||||
exec: 'npx',
|
||||
},
|
||||
yarn: {
|
||||
createWorkspace: `npx ${
|
||||
npmMajorVersion && +npmMajorVersion >= 7 ? '--yes' : ''
|
||||
} create-nx-workspace@${publishedVersion}`,
|
||||
run: (script: string, args: string) => `yarn ${script} ${args}`,
|
||||
runNx: `yarn nx`,
|
||||
runNxSilent:
|
||||
yarnMajorVersion && +yarnMajorVersion >= 2
|
||||
? 'yarn nx'
|
||||
: `yarn --silent nx`,
|
||||
runUninstalledPackage: 'npx --yes',
|
||||
install: 'yarn',
|
||||
ciInstall: 'yarn --frozen-lockfile',
|
||||
addProd: isYarnWorkspace ? 'yarn add -W' : 'yarn add',
|
||||
addDev: isYarnWorkspace ? 'yarn add -DW' : 'yarn add -D',
|
||||
list: 'yarn list --pattern',
|
||||
runLerna:
|
||||
yarnMajorVersion && +yarnMajorVersion >= 2
|
||||
? 'yarn lerna'
|
||||
: `yarn --silent lerna`,
|
||||
exec: 'yarn',
|
||||
},
|
||||
pnpm: {
|
||||
createWorkspace: `pnpm dlx create-nx-workspace@${publishedVersion}`,
|
||||
run: (script: string, args: string) => `pnpm run ${script} -- ${args}`,
|
||||
runNx: `pnpm exec nx`,
|
||||
runNxSilent: `pnpm exec nx`,
|
||||
runUninstalledPackage: 'pnpm dlx',
|
||||
// --no-frozen-lockfile: pnpm detects CI and would otherwise default to --frozen-lockfile.
|
||||
install: 'pnpm install --no-frozen-lockfile',
|
||||
ciInstall: 'pnpm install --frozen-lockfile',
|
||||
addProd: isPnpmWorkspace ? 'pnpm add -w' : 'pnpm add',
|
||||
addDev: isPnpmWorkspace ? 'pnpm add -Dw' : 'pnpm add -D',
|
||||
list: 'pnpm ls --depth 10',
|
||||
runLerna: `pnpm exec lerna`,
|
||||
exec: pnpmVersion && gte(pnpmVersion, '6.13.0') ? 'pnpm exec' : 'pnpx',
|
||||
},
|
||||
bun: {
|
||||
// See note in runCreateWorkspace in create-project-utils.ts for why we don't set @{version} for `bunx create-nx-workspace` right now
|
||||
createWorkspace: `bunx create-nx-workspace`,
|
||||
run: (script: string, args: string) => `bun run ${script} -- ${args}`,
|
||||
runNx: `bunx nx`,
|
||||
runNxSilent: `bunx nx`,
|
||||
runUninstalledPackage: `bunx --yes`,
|
||||
install: 'bun install',
|
||||
ciInstall: 'bun install --no-cache',
|
||||
addProd: 'bun install',
|
||||
addDev: 'bun install -D',
|
||||
list: 'bun pm ls',
|
||||
runLerna: `bunx lerna`,
|
||||
exec: 'bun',
|
||||
},
|
||||
}[packageManager.trim() as PackageManager];
|
||||
}
|
||||
|
||||
export function runE2ETests(runner?: 'cypress' | 'playwright') {
|
||||
if (process.env.NX_E2E_RUN_E2E === 'true') {
|
||||
if (!runner || runner === 'cypress') {
|
||||
ensureCypressInstallation();
|
||||
}
|
||||
if (!runner || runner === 'playwright') {
|
||||
ensurePlaywrightBrowsersInstallation();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'Not running E2E tests because NX_E2E_RUN_E2E is not set to true.'
|
||||
);
|
||||
|
||||
if (process.env.NX_E2E_RUN_CYPRESS) {
|
||||
console.warn(
|
||||
'NX_E2E_RUN_CYPRESS is deprecated, use NX_E2E_RUN_E2E instead.'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function runCommandAsync(
|
||||
command: string,
|
||||
opts: RunCmdOpts = {
|
||||
silenceError: false,
|
||||
env: process['env'],
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string; combinedOutput: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
cwd: opts.cwd || tmpProjPath(),
|
||||
env: {
|
||||
CI: 'true',
|
||||
// Force daemon on under CI (matches runCLI); override via opts.daemon = false.
|
||||
NX_DAEMON: opts.daemon === false ? 'false' : 'true',
|
||||
// Use new versioning by default in e2e tests
|
||||
NX_INTERNAL_USE_LEGACY_VERSIONING: 'false',
|
||||
...(opts.env || getStrippedEnvironmentVariables()),
|
||||
FORCE_COLOR: 'false',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (!opts.silenceError && err) {
|
||||
logError(`Original command: ${command}`, `${stdout}\n\n${stderr}`);
|
||||
reject(err);
|
||||
}
|
||||
|
||||
const outputs = {
|
||||
stdout: stripVTControlCharacters(stdout),
|
||||
stderr: stripVTControlCharacters(stderr),
|
||||
combinedOutput: stripVTControlCharacters(`${stdout}${stderr}`),
|
||||
};
|
||||
|
||||
if (opts.verbose ?? isVerboseE2ERun()) {
|
||||
output.log({
|
||||
title: `Original command: ${command}`,
|
||||
bodyLines: [outputs.combinedOutput],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
resolve(outputs);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function runCommandUntil(
|
||||
command: string,
|
||||
criteria: (output: string) => boolean,
|
||||
opts: RunCmdOpts & { timeout?: number } = {}
|
||||
): Promise<ChildProcess> {
|
||||
const pm = getPackageManagerCommand();
|
||||
const timeout = opts.timeout ?? 30_000;
|
||||
const p = exec(`${pm.runNx} ${command}`, {
|
||||
cwd: tmpProjPath(),
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
CI: 'true',
|
||||
NX_DAEMON: 'true',
|
||||
// Use new versioning by default in e2e tests
|
||||
NX_INTERNAL_USE_LEGACY_VERSIONING: 'false',
|
||||
...getStrippedEnvironmentVariables(),
|
||||
...opts.env,
|
||||
FORCE_COLOR: 'false',
|
||||
},
|
||||
windowsHide: false,
|
||||
});
|
||||
return new Promise((res, rej) => {
|
||||
let output = '';
|
||||
let complete = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!complete) {
|
||||
complete = true;
|
||||
p.kill();
|
||||
logError(
|
||||
`Output did not meet the criteria:`,
|
||||
output
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
);
|
||||
rej(new Error(`Timed out after ${timeout}ms waiting for criteria`));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
function checkCriteria(c) {
|
||||
output += c.toString();
|
||||
const strippedOutput = stripVTControlCharacters(output);
|
||||
if (criteria(strippedOutput) && !complete) {
|
||||
complete = true;
|
||||
clearTimeout(timeoutId);
|
||||
res(p);
|
||||
}
|
||||
}
|
||||
|
||||
p.stdout?.on('data', checkCriteria);
|
||||
p.stderr?.on('data', checkCriteria);
|
||||
p.on('close', (code) => {
|
||||
if (!complete) {
|
||||
complete = true;
|
||||
clearTimeout(timeoutId);
|
||||
logError(
|
||||
`Original output:`,
|
||||
output
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
);
|
||||
rej(new Error(`Exited with ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runCLIAsync(
|
||||
command: string,
|
||||
opts: RunCmdOpts = {
|
||||
silenceError: false,
|
||||
env: getStrippedEnvironmentVariables(),
|
||||
silent: false,
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string; combinedOutput: string }> {
|
||||
const pm = getPackageManagerCommand();
|
||||
const commandToRun = `${opts.silent ? pm.runNxSilent : pm.runNx} ${command} ${
|
||||
(opts.verbose ?? isVerboseE2ERun()) ? ' --verbose' : ''
|
||||
}${opts.redirectStderr ? ' 2>&1' : ''}`;
|
||||
|
||||
return runCommandAsync(commandToRun, opts);
|
||||
}
|
||||
|
||||
export function runNgAdd(
|
||||
packageName: string,
|
||||
command?: string,
|
||||
version: string = getPublishedVersion(),
|
||||
opts: RunCmdOpts = {
|
||||
silenceError: false,
|
||||
env: undefined,
|
||||
cwd: tmpProjPath(),
|
||||
}
|
||||
): string {
|
||||
try {
|
||||
const pmc = getPackageManagerCommand();
|
||||
packageInstall(packageName, undefined, version);
|
||||
const fullCommand = pmc.run(`ng g ${packageName}:ng-add`, command ?? '');
|
||||
const result = execSync(fullCommand, {
|
||||
cwd: tmpProjPath(),
|
||||
stdio: 'pipe',
|
||||
env: { ...(opts.env || getStrippedEnvironmentVariables()) },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const r = stripVTControlCharacters(result);
|
||||
|
||||
if (opts.verbose ?? isVerboseE2ERun()) {
|
||||
output.log({
|
||||
title: `Original command: ${fullCommand}`,
|
||||
bodyLines: [result as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (opts.silenceError) {
|
||||
return e.stdout;
|
||||
} else {
|
||||
logError(`Ng Add failed: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the run-to-run durations / core counts in Nx's performance report with
|
||||
* stable placeholders so the report can stay in snapshots. Scoped to the report
|
||||
* block (from `Run duration:` to the next `NX` section header or end of output);
|
||||
* no-op when no report is present.
|
||||
*/
|
||||
export function normalizePerformanceReport(output: string): string {
|
||||
return output.replace(
|
||||
/\n[ \t]*Run duration:[\s\S]*?(?=\n[ \t]*\n(?:[ \t]*\n)*[ \t]*NX |\s*$)/g,
|
||||
(block) =>
|
||||
block
|
||||
// Durations: match the minute form ("1m 30s") first so its "30s" isn't matched
|
||||
// alone; the optional "<" also captures a "<1ms" (sub-millisecond) duration.
|
||||
.replace(/<?(?:\b\d+m \d+s\b|\b\d+(?:\.\d+)?m?s\b)/g, '{DURATION}')
|
||||
.replace(/\b\d+(?= cores?\b)/g, '{CORES}')
|
||||
// Longest-tasks list right-aligns durations (padStart); collapse the varying
|
||||
// id→duration gap back to a fixed 4-space separator so the table is deterministic.
|
||||
.replace(/^([ \t]+\S+) {4,}(\{DURATION\})$/gm, '$1 $2')
|
||||
// Stat rows align values by padding the label column, whose width may change;
|
||||
// collapse the label→value gap so the snapshot doesn't depend on that padding.
|
||||
.replace(
|
||||
/^([ \t]*(?:Run duration|Cache|Critical path|Recoverable time):) +/gm,
|
||||
'$1 '
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function runCLI(
|
||||
command: string,
|
||||
opts: RunCmdOpts = {
|
||||
silenceError: false,
|
||||
env: undefined,
|
||||
verbose: undefined,
|
||||
redirectStderr: undefined,
|
||||
}
|
||||
): string {
|
||||
const timeoutMs = opts.timeout ?? 5 * 60 * 1000;
|
||||
try {
|
||||
const pm = getPackageManagerCommand();
|
||||
const commandToRun = `${pm.runNxSilent} ${command} ${
|
||||
(opts.verbose ?? isVerboseE2ERun()) ? ' --verbose' : ''
|
||||
}${opts.redirectStderr ? ' 2>&1' : ''}`;
|
||||
logInfo(`Run Command: ${command}`);
|
||||
const startTime = performance.now();
|
||||
const result = execSync(commandToRun, {
|
||||
cwd: opts.cwd || tmpProjPath(),
|
||||
env: {
|
||||
CI: 'true',
|
||||
// Daemon is normally off under CI; force it on so e2e exercises the same
|
||||
// daemon-driven graph + watcher path real users hit. Override via opts.daemon = false.
|
||||
NX_DAEMON: opts.daemon === false ? 'false' : 'true',
|
||||
// Use new versioning by default in e2e tests
|
||||
NX_INTERNAL_USE_LEGACY_VERSIONING: 'false',
|
||||
...getStrippedEnvironmentVariables(),
|
||||
...opts.env,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||
logInfo(`Run Command: ${command} (${elapsed}s)`);
|
||||
|
||||
if (opts.verbose ?? isVerboseE2ERun()) {
|
||||
output.log({
|
||||
title: `Original command: ${command}`,
|
||||
bodyLines: [result as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
const r = stripVTControlCharacters(result);
|
||||
|
||||
runCLI.lastExitCode = 0;
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (e.killed || e.signal) {
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
const processOutput = stripVTControlCharacters(
|
||||
`${e.stdout ?? ''}\n\n${e.stderr ?? ''}`
|
||||
).trim();
|
||||
const msg = `Command timed out after ${timeoutSec}s: ${command}\n\nProcess output:\n${processOutput}`;
|
||||
logError(`Command timed out`, msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (opts.silenceError) {
|
||||
runCLI.lastExitCode = (e.status ?? 1) as number;
|
||||
// Without redirectStderr the shell didn't merge stderr into stdout, so concat both.
|
||||
const output = opts.redirectStderr ? e.stdout : e.stdout + e.stderr;
|
||||
return stripVTControlCharacters(output);
|
||||
} else {
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
runCLI.lastExitCode = 0 as number;
|
||||
|
||||
export function runLernaCLI(
|
||||
command: string,
|
||||
opts: RunCmdOpts = {
|
||||
silenceError: false,
|
||||
env: undefined,
|
||||
}
|
||||
): string {
|
||||
const timeoutMs = opts.timeout ?? 2 * 60 * 1000;
|
||||
try {
|
||||
const pm = getPackageManagerCommand();
|
||||
const fullCommand = `${pm.runLerna} ${command}`;
|
||||
const logs = execSync(fullCommand, {
|
||||
cwd: opts.cwd || tmpProjPath(),
|
||||
env: {
|
||||
CI: 'true',
|
||||
...(opts.env || getStrippedEnvironmentVariables()),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
if (opts.verbose ?? isVerboseE2ERun()) {
|
||||
output.log({
|
||||
title: `Original command: ${fullCommand}`,
|
||||
bodyLines: [logs as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
const r = stripVTControlCharacters(logs);
|
||||
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (e.killed || e.signal) {
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
const processOutput = stripVTControlCharacters(
|
||||
`${e.stdout ?? ''}\n\n${e.stderr ?? ''}`
|
||||
).trim();
|
||||
const msg = `Command timed out after ${timeoutSec}s: ${command}\n\nProcess output:\n${processOutput}`;
|
||||
logError(`Command timed out`, msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (opts.silenceError) {
|
||||
return stripVTControlCharacters(e.stdout + e.stderr);
|
||||
} else {
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function waitUntil(
|
||||
predicate: () => boolean,
|
||||
opts: { timeout: number; ms: number; allowError?: boolean } = {
|
||||
timeout: 5000,
|
||||
ms: 50,
|
||||
}
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setInterval(() => {
|
||||
const run = () => {};
|
||||
try {
|
||||
run();
|
||||
if (predicate()) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
if (opts.allowError) reject(e);
|
||||
}
|
||||
}, opts.ms);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(t);
|
||||
reject(new Error(`Timed out waiting for condition to return true`));
|
||||
}, opts.timeout);
|
||||
});
|
||||
}
|
||||
|
||||
export function isDockerAvailable() {
|
||||
try {
|
||||
const dockerVersionInfo = runCommand(`docker info -f json`);
|
||||
return !dockerVersionInfo.includes('Cannot connect to the Docker daemon');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
import { copySync, ensureDirSync, moveSync, removeSync } from 'fs-extra';
|
||||
import * as isCI from 'is-ci';
|
||||
import {
|
||||
createFile,
|
||||
directoryExists,
|
||||
tmpBackupNgCliProjPath,
|
||||
tmpBackupProjPath,
|
||||
updateFile,
|
||||
updateJson,
|
||||
} from './file-utils';
|
||||
import {
|
||||
e2eCwd,
|
||||
getLatestLernaVersion,
|
||||
getPublishedVersion,
|
||||
getSelectedPackageManager,
|
||||
getStrippedEnvironmentVariables,
|
||||
isVerbose,
|
||||
isVerboseE2ERun,
|
||||
} from './get-env-info';
|
||||
|
||||
import { output, readJsonFile } from '@nx/devkit';
|
||||
import { angularDevkitVersion as defaultAngularCliVersion } from '@nx/angular/internal';
|
||||
import { dump } from '@zkochan/js-yaml';
|
||||
import { execSync, ExecSyncOptions } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { performance, PerformanceMeasure } from 'node:perf_hooks';
|
||||
import { resetWorkspaceContext } from 'nx/src/utils/workspace-context';
|
||||
import {
|
||||
getPackageManagerCommand,
|
||||
runCLI,
|
||||
RunCmdOpts,
|
||||
runCommand,
|
||||
} from './command-utils';
|
||||
import { logError, logInfo } from './log-utils';
|
||||
|
||||
let projName: string;
|
||||
|
||||
// TODO(jack): we should tag the projects (e.g. tags: ['package']) and filter from that rather than hard-code packages.
|
||||
const nxPackages = [
|
||||
`@nx/angular`,
|
||||
`@nx/cypress`,
|
||||
`@nx/docker`,
|
||||
`@nx/eslint-plugin`,
|
||||
`@nx/express`,
|
||||
`@nx/esbuild`,
|
||||
`@nx/gradle`,
|
||||
`@nx/jest`,
|
||||
`@nx/js`,
|
||||
`@nx/eslint`,
|
||||
'@nx/maven',
|
||||
`@nx/nest`,
|
||||
`@nx/next`,
|
||||
`@nx/node`,
|
||||
`@nx/nuxt`,
|
||||
`@nx/plugin`,
|
||||
`@nx/playwright`,
|
||||
`@nx/rollup`,
|
||||
`@nx/react`,
|
||||
`@nx/remix`,
|
||||
`@nx/rsbuild`,
|
||||
`@nx/rspack`,
|
||||
`@nx/storybook`,
|
||||
`@nx/vue`,
|
||||
`@nx/vite`,
|
||||
`@nx/vitest`,
|
||||
`@nx/web`,
|
||||
`@nx/webpack`,
|
||||
`@nx/react-native`,
|
||||
`@nx/expo`,
|
||||
'@nx/dotnet',
|
||||
`@nx/module-federation`,
|
||||
`@nx/workspace`,
|
||||
] as const;
|
||||
|
||||
type NxPackage = (typeof nxPackages)[number];
|
||||
|
||||
export function openInEditor(projectDirectory: string = tmpProjPath()) {
|
||||
if (process.env.NX_E2E_EDITOR) {
|
||||
const editor = process.env.NX_E2E_EDITOR;
|
||||
execSync(`${editor} ${projectDirectory}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a new project in the temporary project path
|
||||
* for the currently selected CLI.
|
||||
*/
|
||||
export function newProject({
|
||||
name = uniq('proj'),
|
||||
packageManager = getSelectedPackageManager(),
|
||||
packages,
|
||||
preset = 'apps',
|
||||
}: {
|
||||
name?: string;
|
||||
packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
readonly packages?: Array<NxPackage>;
|
||||
preset?: string;
|
||||
} = {}): string {
|
||||
const newProjectStart = performance.mark('new-project:start');
|
||||
try {
|
||||
const projScope = 'proj';
|
||||
|
||||
let createNxWorkspaceMeasure: PerformanceMeasure;
|
||||
let packageInstallMeasure: PerformanceMeasure;
|
||||
|
||||
// Namespace by package manager to avoid conflicts in test suites which include multiple package managers
|
||||
const backupPath = tmpBackupProjPath(packageManager);
|
||||
|
||||
if (!directoryExists(backupPath)) {
|
||||
const createNxWorkspaceStart = performance.mark(
|
||||
'create-nx-workspace:start'
|
||||
);
|
||||
runCreateWorkspace(projScope, {
|
||||
preset,
|
||||
packageManager,
|
||||
});
|
||||
const createNxWorkspaceEnd = performance.mark('create-nx-workspace:end');
|
||||
createNxWorkspaceMeasure = performance.measure(
|
||||
'create-nx-workspace',
|
||||
createNxWorkspaceStart.name,
|
||||
createNxWorkspaceEnd.name
|
||||
);
|
||||
|
||||
// Workaround: pnpm defaults to frozen-lockfile in CI, but e2e tests
|
||||
// dynamically add packages after workspace creation
|
||||
if (isCI && packageManager === 'pnpm') {
|
||||
updateFile('.npmrc', 'prefer-frozen-lockfile=false');
|
||||
}
|
||||
|
||||
let packagesToInstall: Array<NxPackage> = [];
|
||||
if (!packages) {
|
||||
console.warn(
|
||||
'ATTENTION: All packages are installed into the new workspace. To make this test faster, please pass the subset of packages that this test needs by passing a packages array in the options'
|
||||
);
|
||||
packagesToInstall = [...nxPackages];
|
||||
} else if (packages.length > 0) {
|
||||
packagesToInstall = packages;
|
||||
}
|
||||
|
||||
if (packagesToInstall.length) {
|
||||
const packageInstallStart = performance.mark('packageInstall:start');
|
||||
packageInstall(packagesToInstall.join(` `), projScope);
|
||||
const packageInstallEnd = performance.mark('packageInstall:end');
|
||||
packageInstallMeasure = performance.measure(
|
||||
'packageInstall',
|
||||
packageInstallStart.name,
|
||||
packageInstallEnd.name
|
||||
);
|
||||
} else {
|
||||
console.info('No packages to install');
|
||||
}
|
||||
// stop the daemon
|
||||
execSync(`${getPackageManagerCommand({ packageManager }).runNx} reset`, {
|
||||
cwd: `${e2eCwd}/proj`,
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
});
|
||||
|
||||
moveSync(`${e2eCwd}/proj`, backupPath);
|
||||
}
|
||||
projName = name;
|
||||
|
||||
const projectDirectory = tmpProjPath();
|
||||
copySync(backupPath, projectDirectory);
|
||||
|
||||
const dependencies = readJsonFile(
|
||||
`${projectDirectory}/package.json`
|
||||
).devDependencies;
|
||||
const missingPackages = (packages || []).filter((p) => !dependencies[p]);
|
||||
|
||||
if (missingPackages.length > 0) {
|
||||
packageInstall(missingPackages.join(` `), projName);
|
||||
} else if (packageManager === 'pnpm') {
|
||||
// pnpm creates sym links to the pnpm store,
|
||||
// we need to run the install again after copying the temp folder
|
||||
try {
|
||||
execSync(getPackageManagerCommand().install, {
|
||||
cwd: projectDirectory,
|
||||
stdio: 'pipe',
|
||||
env: { CI: 'true', ...process.env },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('newProject() - reinstall pnpm dependencies failed');
|
||||
console.error('Full error:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const newProjectEnd = performance.mark('new-project:end');
|
||||
const perfMeasure = performance.measure(
|
||||
'newProject',
|
||||
newProjectStart.name,
|
||||
newProjectEnd.name
|
||||
);
|
||||
|
||||
logInfo(
|
||||
`NX`,
|
||||
`E2E created a project: ${projectDirectory} in ${
|
||||
perfMeasure.duration / 1000
|
||||
} seconds
|
||||
${
|
||||
createNxWorkspaceMeasure
|
||||
? `create-nx-workspace: ${
|
||||
createNxWorkspaceMeasure.duration / 1000
|
||||
} seconds\n`
|
||||
: ''
|
||||
}${
|
||||
packageInstallMeasure
|
||||
? `packageInstall: ${packageInstallMeasure.duration / 1000} seconds\n`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
|
||||
openInEditor(projectDirectory);
|
||||
return projScope;
|
||||
} catch (e) {
|
||||
logError(`Failed to set up project for e2e tests.`, e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function runCreateWorkspace(
|
||||
name: string,
|
||||
{
|
||||
preset,
|
||||
template,
|
||||
appName,
|
||||
style,
|
||||
base,
|
||||
packageManager,
|
||||
extraArgs,
|
||||
useDetectedPm = false,
|
||||
cwd = e2eCwd,
|
||||
bundler,
|
||||
routing,
|
||||
useReactRouter,
|
||||
standaloneApi,
|
||||
docker,
|
||||
nextAppDir,
|
||||
nextSrcDir,
|
||||
linter = 'eslint',
|
||||
formatter = 'prettier',
|
||||
unitTestRunner,
|
||||
e2eTestRunner,
|
||||
ssr,
|
||||
framework,
|
||||
prefix,
|
||||
}: {
|
||||
preset?: string;
|
||||
template?: string;
|
||||
appName?: string;
|
||||
style?: string;
|
||||
base?: string;
|
||||
packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
extraArgs?: string;
|
||||
useDetectedPm?: boolean;
|
||||
cwd?: string;
|
||||
bundler?: 'webpack' | 'vite';
|
||||
standaloneApi?: boolean;
|
||||
routing?: boolean;
|
||||
useReactRouter?: boolean;
|
||||
docker?: boolean;
|
||||
nextAppDir?: boolean;
|
||||
nextSrcDir?: boolean;
|
||||
linter?: 'none' | 'eslint';
|
||||
unitTestRunner?:
|
||||
| 'jest'
|
||||
| 'vitest'
|
||||
| 'vitest-angular'
|
||||
| 'vitest-analog'
|
||||
| 'none';
|
||||
e2eTestRunner?: 'cypress' | 'playwright' | 'jest' | 'detox' | 'none';
|
||||
formatter?: 'prettier' | 'none';
|
||||
ssr?: boolean;
|
||||
framework?: string;
|
||||
prefix?: string;
|
||||
}
|
||||
) {
|
||||
if (preset && template) {
|
||||
throw new Error(
|
||||
'Cannot specify both preset and template. Use one or the other.'
|
||||
);
|
||||
}
|
||||
if (!preset && !template) {
|
||||
throw new Error('Must specify either preset or template.');
|
||||
}
|
||||
|
||||
projName = name;
|
||||
|
||||
const pm = getPackageManagerCommand({ packageManager });
|
||||
|
||||
// Needed for bun workarounds, see below
|
||||
const registry = execSync('npm config get registry').toString().trim();
|
||||
|
||||
let command = `${pm.createWorkspace} ${name} --no-interactive`;
|
||||
|
||||
if (preset) {
|
||||
command += ` --preset=${preset}`;
|
||||
}
|
||||
if (template) {
|
||||
command += ` --template=${template}`;
|
||||
}
|
||||
|
||||
if (appName) {
|
||||
command += ` --appName=${appName}`;
|
||||
}
|
||||
if (style) {
|
||||
command += ` --style=${style}`;
|
||||
}
|
||||
|
||||
if (bundler) {
|
||||
command += ` --bundler=${bundler}`;
|
||||
}
|
||||
|
||||
if (nextAppDir !== undefined) {
|
||||
command += ` --nextAppDir=${nextAppDir}`;
|
||||
}
|
||||
|
||||
if (nextSrcDir !== undefined) {
|
||||
command += ` --nextSrcDir=${nextSrcDir}`;
|
||||
}
|
||||
|
||||
if (docker !== undefined) {
|
||||
command += ` --docker=${docker}`;
|
||||
}
|
||||
|
||||
if (standaloneApi !== undefined) {
|
||||
command += ` --standaloneApi=${standaloneApi}`;
|
||||
}
|
||||
|
||||
if (routing !== undefined) {
|
||||
command += ` --routing=${routing}`;
|
||||
}
|
||||
|
||||
if (useReactRouter !== undefined) {
|
||||
command += ` --useReactRouter=${useReactRouter}`;
|
||||
}
|
||||
|
||||
if (base) {
|
||||
command += ` --defaultBase="${base}"`;
|
||||
}
|
||||
|
||||
if (packageManager && !useDetectedPm) {
|
||||
command += ` --package-manager=${packageManager}`;
|
||||
}
|
||||
|
||||
if (linter) {
|
||||
command += ` --linter=${linter}`;
|
||||
}
|
||||
|
||||
if (formatter) {
|
||||
command += ` --formatter=${formatter}`;
|
||||
}
|
||||
|
||||
if (unitTestRunner) {
|
||||
command += ` --unitTestRunner=${unitTestRunner}`;
|
||||
}
|
||||
|
||||
if (e2eTestRunner) {
|
||||
command += ` --e2eTestRunner=${e2eTestRunner}`;
|
||||
}
|
||||
|
||||
if (framework) {
|
||||
command += ` --framework=${framework}`;
|
||||
}
|
||||
|
||||
if (extraArgs) {
|
||||
command += ` ${extraArgs}`;
|
||||
}
|
||||
|
||||
if (isCI) {
|
||||
command += ` --verbose`;
|
||||
}
|
||||
|
||||
if (ssr !== undefined) {
|
||||
command += ` --ssr=${ssr}`;
|
||||
}
|
||||
|
||||
if (prefix !== undefined) {
|
||||
command += ` --prefix=${prefix}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const create = execSync(`${command}${isVerbose() ? ' --verbose' : ''}`, {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
CI: 'true',
|
||||
NX_VERBOSE_LOGGING: isCI ? 'true' : 'false',
|
||||
...getStrippedEnvironmentVariables(),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (isVerbose()) {
|
||||
output.log({
|
||||
title: `Command: ${command}`,
|
||||
bodyLines: [create as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return create;
|
||||
} catch (e) {
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function runCreatePlugin(
|
||||
name: string,
|
||||
{
|
||||
packageManager,
|
||||
extraArgs,
|
||||
useDetectedPm = false,
|
||||
}: {
|
||||
packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
extraArgs?: string;
|
||||
useDetectedPm?: boolean;
|
||||
}
|
||||
) {
|
||||
projName = name;
|
||||
|
||||
const pm = getPackageManagerCommand({ packageManager });
|
||||
|
||||
let command = `${
|
||||
pm.runUninstalledPackage
|
||||
} create-nx-plugin@${getPublishedVersion()} ${name} --nxCloud=skip --no-interactive`;
|
||||
|
||||
if (packageManager && !useDetectedPm) {
|
||||
command += ` --package-manager=${packageManager}`;
|
||||
}
|
||||
|
||||
if (extraArgs) {
|
||||
command += ` ${extraArgs}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const create = execSync(`${command}${isVerbose() ? ' --verbose' : ''}`, {
|
||||
cwd: e2eCwd,
|
||||
stdio: 'pipe',
|
||||
env: process.env,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (isVerbose()) {
|
||||
output.log({
|
||||
title: `Command: ${command}`,
|
||||
bodyLines: [create as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return create;
|
||||
} catch (e) {
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function packageInstall(
|
||||
pkg: string,
|
||||
projName?: string,
|
||||
version = getPublishedVersion(),
|
||||
mode: 'dev' | 'prod' = 'dev'
|
||||
) {
|
||||
const cwd = projName ? `${e2eCwd}/${projName}` : tmpProjPath();
|
||||
const pm = getPackageManagerCommand({ path: cwd });
|
||||
const pkgsWithVersions = pkg
|
||||
.split(' ')
|
||||
.map((pgk) => `${pgk}@${version}`)
|
||||
.join(' ');
|
||||
|
||||
const command = `${
|
||||
mode === 'dev' ? pm.addDev : pm.addProd
|
||||
} ${pkgsWithVersions}`;
|
||||
|
||||
try {
|
||||
const install = execSync(command, {
|
||||
cwd,
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: process.env,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (isVerbose()) {
|
||||
output.log({
|
||||
title: `Command: ${command}`,
|
||||
bodyLines: [install as string],
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return install;
|
||||
} catch (e) {
|
||||
logError(`Original command: ${command}`, `${e.stdout}\n\n${e.stderr}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Angular CLI workspace or restores a cached one if exists.
|
||||
* @returns the workspace name
|
||||
*/
|
||||
export function runNgNew(
|
||||
packageManager = getSelectedPackageManager(),
|
||||
angularCliVersion = defaultAngularCliVersion
|
||||
): string {
|
||||
const pmc = getPackageManagerCommand({ packageManager });
|
||||
|
||||
if (directoryExists(tmpBackupNgCliProjPath())) {
|
||||
const angularJson = JSON.parse(
|
||||
readFileSync(join(tmpBackupNgCliProjPath(), 'angular.json'), 'utf-8')
|
||||
);
|
||||
// the name of the workspace matches the name of the generated default app,
|
||||
// we need to reuse the same name that's cached in order to avoid issues
|
||||
// with tests relying on a different name
|
||||
projName = Object.keys(angularJson.projects)[0];
|
||||
// The cached Angular CLI workspace includes symlinked node_modules/.bin entries.
|
||||
// If cleanup left the destination behind, fs-extra will fail while overwriting
|
||||
// those links, so always start from a clean restore target.
|
||||
removeSync(tmpProjPath());
|
||||
copySync(tmpBackupNgCliProjPath(), tmpProjPath());
|
||||
|
||||
if (isVerboseE2ERun()) {
|
||||
logInfo(
|
||||
`NX`,
|
||||
`E2E restored an Angular CLI project from cache: ${tmpProjPath()}`
|
||||
);
|
||||
}
|
||||
|
||||
return projName;
|
||||
}
|
||||
|
||||
projName = uniq('ng-proj');
|
||||
const command = `${pmc.runUninstalledPackage} @angular/cli@${angularCliVersion} new ${projName} --package-manager=${packageManager}`;
|
||||
execSync(command, {
|
||||
cwd: e2eCwd,
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: process.env,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
// ensure angular packages are installed with ~ instead of ^ to prevent
|
||||
// potential failures when new minor versions are released
|
||||
function updateAngularDependencies(dependencies: any): void {
|
||||
Object.keys(dependencies).forEach((key) => {
|
||||
if (key.startsWith('@angular/') || key.startsWith('@angular-devkit/')) {
|
||||
dependencies[key] = dependencies[key].replace(/^\^/, '~');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateJson('package.json', (json) => {
|
||||
updateAngularDependencies(json.dependencies ?? {});
|
||||
updateAngularDependencies(json.devDependencies ?? {});
|
||||
return json;
|
||||
});
|
||||
|
||||
execSync(pmc.install, {
|
||||
cwd: join(e2eCwd, projName),
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: process.env,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
copySync(tmpProjPath(), tmpBackupNgCliProjPath());
|
||||
|
||||
if (isVerboseE2ERun()) {
|
||||
logInfo(`NX`, `E2E created an Angular CLI project: ${tmpProjPath()}`);
|
||||
}
|
||||
|
||||
return projName;
|
||||
}
|
||||
|
||||
export function newLernaWorkspace({
|
||||
name = uniq('lerna-proj'),
|
||||
packageManager = getSelectedPackageManager(),
|
||||
} = {}): string {
|
||||
try {
|
||||
const projScope = name;
|
||||
projName = name;
|
||||
|
||||
const pm = getPackageManagerCommand({ packageManager });
|
||||
|
||||
createNonNxProjectDirectory(projScope, packageManager !== 'pnpm');
|
||||
|
||||
if (packageManager === 'pnpm') {
|
||||
updateFile(
|
||||
'pnpm-workspace.yaml',
|
||||
dump({
|
||||
packages: ['packages/*'],
|
||||
})
|
||||
);
|
||||
updateFile(
|
||||
'.npmrc',
|
||||
'prefer-frozen-lockfile=false\nstrict-peer-dependencies=false\nauto-install-peers=true'
|
||||
);
|
||||
}
|
||||
|
||||
if (isVerbose()) {
|
||||
logInfo(`NX`, `E2E test has created a lerna workspace: ${tmpProjPath()}`);
|
||||
}
|
||||
|
||||
// We need to force the real latest version of lerna to depend on our locally published version of nx
|
||||
updateJson(`package.json`, (json) => {
|
||||
// yarn workspaces can only be enabled in private projects
|
||||
json.private = true;
|
||||
|
||||
const nxVersion = getPublishedVersion();
|
||||
const overrides = {
|
||||
...json.overrides,
|
||||
nx: nxVersion,
|
||||
'@nx/devkit': nxVersion,
|
||||
};
|
||||
if (packageManager === 'pnpm') {
|
||||
json.pnpm = {
|
||||
...json.pnpm,
|
||||
overrides: {
|
||||
...json.pnpm?.overrides,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
} else if (packageManager === 'yarn') {
|
||||
json.resolutions = {
|
||||
...json.resolutions,
|
||||
...overrides,
|
||||
};
|
||||
} else if (packageManager === 'bun') {
|
||||
json.overrides = {
|
||||
...json.resolutions,
|
||||
...overrides,
|
||||
};
|
||||
} else {
|
||||
json.overrides = overrides;
|
||||
}
|
||||
return json;
|
||||
});
|
||||
|
||||
/**
|
||||
* Again, in order to ensure we override the required version relationships, we first install lerna as a devDep
|
||||
* before running `lerna init`.
|
||||
*/
|
||||
execSync(
|
||||
`${pm.addDev} lerna@${getLatestLernaVersion()}${
|
||||
packageManager === 'pnpm'
|
||||
? ' --workspace-root'
|
||||
: packageManager === 'yarn'
|
||||
? ' -W'
|
||||
: ''
|
||||
}`,
|
||||
{
|
||||
cwd: tmpProjPath(),
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: { CI: 'true', ...process.env },
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
);
|
||||
|
||||
execSync(`${pm.runLerna} init`, {
|
||||
cwd: tmpProjPath(),
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: { CI: 'true', ...process.env },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (packageManager === 'pnpm') {
|
||||
// pnpm doesn't use the normal package.json packages field so lerna needs to be told that pnpm is the client.
|
||||
updateJson('lerna.json', (json) => {
|
||||
json.npmClient = 'pnpm';
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
execSync(pm.install, {
|
||||
cwd: tmpProjPath(),
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
env: { CI: 'true', ...process.env },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
// Format files to ensure no changes are made during lerna repair
|
||||
execSync(`${pm.runUninstalledPackage} prettier . --write`, {
|
||||
cwd: tmpProjPath(),
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
return projScope;
|
||||
} catch (e) {
|
||||
logError(`Failed to set up lerna workspace for e2e tests.`, e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function newWrappedNxWorkspace({
|
||||
name = uniq('wrapped'),
|
||||
pmc = getPackageManagerCommand(),
|
||||
} = {}): (command: string, opts?: Partial<ExecSyncOptions>) => string {
|
||||
projName = name;
|
||||
ensureDirSync(tmpProjPath());
|
||||
runCommand(
|
||||
`${pmc.runUninstalledPackage} nx@latest init --use-dot-nx-installation`
|
||||
);
|
||||
return (command: string, opts: Partial<ExecSyncOptions> | undefined) => {
|
||||
if (process.platform === 'win32') {
|
||||
return runCommand(`./nx.bat ${command}`, { ...opts, failOnError: true });
|
||||
} else {
|
||||
return runCommand(`./nx ${command}`, { ...opts, failOnError: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getProjectName(): string {
|
||||
return projName;
|
||||
}
|
||||
|
||||
export function tmpProjPath(path?: string) {
|
||||
return path ? `${e2eCwd}/${projName}/${path}` : `${e2eCwd}/${projName}`;
|
||||
}
|
||||
|
||||
export function createNonNxProjectDirectory(
|
||||
name = uniq('proj'),
|
||||
addWorkspaces = true
|
||||
) {
|
||||
projName = name;
|
||||
ensureDirSync(tmpProjPath());
|
||||
createFile(
|
||||
'package.json',
|
||||
JSON.stringify({
|
||||
name,
|
||||
workspaces: addWorkspaces ? ['packages/*'] : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function createEmptyProjectDirectory(name = uniq('proj')) {
|
||||
projName = name;
|
||||
ensureDirSync(tmpProjPath());
|
||||
}
|
||||
|
||||
export function uniq(prefix: string): string {
|
||||
// We need to ensure that the length of the random section of the name is of consistent length to avoid flakiness in tests
|
||||
const randomSevenDigitNumber = Math.floor(Math.random() * 10000000)
|
||||
.toString()
|
||||
.padStart(7, '0');
|
||||
return `${prefix}${randomSevenDigitNumber}`;
|
||||
}
|
||||
|
||||
// Useful in order to cleanup space during CI to prevent `No space left on device` exceptions
|
||||
export function cleanupProject({
|
||||
skipReset,
|
||||
...opts
|
||||
}: RunCmdOpts & { skipReset?: boolean } = {}) {
|
||||
if (process.env.NX_E2E_SKIP_PROJECT_CLEANUP) {
|
||||
resetWorkspaceContext();
|
||||
return;
|
||||
}
|
||||
if (isCI) {
|
||||
// Stopping the daemon is not required for tests to pass, but it cleans up background processes
|
||||
try {
|
||||
if (!skipReset) {
|
||||
runCLI('reset', opts);
|
||||
}
|
||||
} catch {} // ignore crashed daemon
|
||||
try {
|
||||
removeSync(tmpProjPath());
|
||||
} catch {}
|
||||
}
|
||||
resetWorkspaceContext();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { tmpProjPath } from './create-project-utils';
|
||||
import { e2eConsoleLogger } from './log-utils';
|
||||
import { isVerbose } from './get-env-info';
|
||||
|
||||
// Helper files to prevent multiple `npx playwright install` on the same machine.
|
||||
// Doing so will cause `apt` to fail on Linux machines.
|
||||
const LOCK_DIR = join(tmpdir(), 'nx-e2e-locks');
|
||||
const PLAYWRIGHT_LOCK_FILE = join(LOCK_DIR, 'playwright-install.lock');
|
||||
const PLAYWRIGHT_STATUS_FILE = join(LOCK_DIR, 'playwright-install.status');
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const MAX_WAIT_MS = 5 * 60 * 1000;
|
||||
|
||||
interface InstallStatus {
|
||||
status: 'installing' | 'success' | 'failed';
|
||||
pid: number;
|
||||
timestamp: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ensureLockDir() {
|
||||
if (!existsSync(LOCK_DIR)) {
|
||||
mkdirSync(LOCK_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function tryAcquireLock(lockFile: string, statusFile: string): boolean {
|
||||
ensureLockDir();
|
||||
try {
|
||||
// Atomic operation - fails if file exists
|
||||
const fd = openSync(lockFile, 'wx');
|
||||
closeSync(fd);
|
||||
|
||||
const status: InstallStatus = {
|
||||
status: 'installing',
|
||||
pid: process.pid,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
writeFileSync(statusFile, JSON.stringify(status));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
return false; // Lock already held
|
||||
}
|
||||
throw err; // Unexpected error
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLock(
|
||||
lockFile: string,
|
||||
statusFile: string,
|
||||
success: boolean,
|
||||
error?: string
|
||||
) {
|
||||
try {
|
||||
const status: InstallStatus = {
|
||||
status: success ? 'success' : 'failed',
|
||||
pid: process.pid,
|
||||
timestamp: Date.now(),
|
||||
error: error,
|
||||
};
|
||||
writeFileSync(statusFile, JSON.stringify(status));
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(lockFile);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallStatus(statusFile: string): InstallStatus | null {
|
||||
try {
|
||||
if (!existsSync(statusFile)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(statusFile, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForInstallation(
|
||||
lockFile: string,
|
||||
statusFile: string,
|
||||
toolName: string
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < MAX_WAIT_MS) {
|
||||
// Check if lock is released
|
||||
if (!existsSync(lockFile)) {
|
||||
const status = readInstallStatus(statusFile);
|
||||
|
||||
if (status?.status === 'success') {
|
||||
e2eConsoleLogger(
|
||||
`${toolName} browsers installed by process ${status.pid}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status?.status === 'failed') {
|
||||
const errorMsg = `${toolName} browser installation failed in process ${
|
||||
status.pid
|
||||
}: ${status.error || 'Unknown error'}`;
|
||||
e2eConsoleLogger(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before polling again
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout waiting for ${toolName} browser installation to complete`
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureCypressInstallation() {
|
||||
let cypressVerified = true;
|
||||
try {
|
||||
const r = execSync('npx cypress verify', {
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpProjPath(),
|
||||
});
|
||||
if (r.indexOf('Verified Cypress!') === -1) {
|
||||
cypressVerified = false;
|
||||
}
|
||||
} catch {
|
||||
cypressVerified = false;
|
||||
} finally {
|
||||
if (!cypressVerified) {
|
||||
e2eConsoleLogger('Cypress was not verified. Installing Cypress now.');
|
||||
execSync('npx cypress install', {
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpProjPath(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePlaywrightBrowsersInstallation() {
|
||||
// If lock is acquired, perform installation, otherwise it must in progress or already done
|
||||
if (tryAcquireLock(PLAYWRIGHT_LOCK_FILE, PLAYWRIGHT_STATUS_FILE)) {
|
||||
// We got the lock - we're responsible for installation
|
||||
e2eConsoleLogger(
|
||||
`Process ${process.pid} acquired lock, installing Playwright browsers...`
|
||||
);
|
||||
|
||||
const playwrightInstallArgs =
|
||||
process.env.PLAYWRIGHT_INSTALL_ARGS || '--with-deps';
|
||||
|
||||
try {
|
||||
execSync(`npx playwright install ${playwrightInstallArgs}`, {
|
||||
stdio: isVerbose() ? 'inherit' : 'pipe',
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpProjPath(),
|
||||
});
|
||||
|
||||
const version = execSync('npx playwright --version').toString().trim();
|
||||
|
||||
e2eConsoleLogger(
|
||||
`Playwright browsers ${version} installed successfully.`
|
||||
);
|
||||
|
||||
releaseLock(PLAYWRIGHT_LOCK_FILE, PLAYWRIGHT_STATUS_FILE, true);
|
||||
} catch (error) {
|
||||
e2eConsoleLogger('Failed to install Playwright browsers:', error);
|
||||
releaseLock(
|
||||
PLAYWRIGHT_LOCK_FILE,
|
||||
PLAYWRIGHT_STATUS_FILE,
|
||||
false,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
e2eConsoleLogger(
|
||||
`Process ${process.pid} waiting for Playwright browser installation...`
|
||||
);
|
||||
await waitForInstallation(
|
||||
PLAYWRIGHT_LOCK_FILE,
|
||||
PLAYWRIGHT_STATUS_FILE,
|
||||
'Playwright'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { parseJson } from '@nx/devkit';
|
||||
import {
|
||||
createFileSync,
|
||||
ensureDirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
removeSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { e2eCwd } from './get-env-info';
|
||||
import { tmpProjPath } from './create-project-utils';
|
||||
import { join } from 'path';
|
||||
|
||||
export function createFile(f: string, content: string = ''): void {
|
||||
const path = tmpProjPath(f);
|
||||
createFileSync(path);
|
||||
if (content) {
|
||||
updateFile(f, content);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFile(
|
||||
f: string,
|
||||
content: string | ((content: string) => string)
|
||||
): void {
|
||||
ensureDirSync(path.dirname(tmpProjPath(f)));
|
||||
if (typeof content === 'string') {
|
||||
writeFileSync(tmpProjPath(f), content);
|
||||
} else {
|
||||
writeFileSync(
|
||||
tmpProjPath(f),
|
||||
content(readFileSync(tmpProjPath(f)).toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function renameFile(f: string, newPath: string): void {
|
||||
ensureDirSync(path.dirname(tmpProjPath(newPath)));
|
||||
renameSync(tmpProjPath(f), tmpProjPath(newPath));
|
||||
}
|
||||
|
||||
export function checkFilesExist(...expectedFiles: string[]) {
|
||||
expectedFiles.forEach((f) => {
|
||||
const ff = f.startsWith('/') ? f : tmpProjPath(f);
|
||||
if (!exists(ff)) {
|
||||
throw new Error(`File '${ff}' does not exist`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function checkFilesMatchingPatternExist(
|
||||
pattern: string,
|
||||
cwd = tmpProjPath()
|
||||
) {
|
||||
const files = readdirSync(cwd, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
const regex = new RegExp(pattern);
|
||||
const matchedFiles = files
|
||||
.filter((f) => f.isFile() && regex.test(join(f.parentPath, f.name)))
|
||||
.map((f) => f.name);
|
||||
if (matchedFiles.length === 0) {
|
||||
throw new Error(
|
||||
`No files matching pattern '${pattern}' were found.` +
|
||||
`
|
||||
Existing files: \n
|
||||
${files.map((f) => join(f.parentPath, f.name)).join('\n')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateJson<T extends object = any, U extends object = T>(
|
||||
f: string,
|
||||
updater: (value: T) => U
|
||||
) {
|
||||
updateFile(f, (s) => {
|
||||
const json = JSON.parse(s);
|
||||
return JSON.stringify(updater(json), null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
export function checkFilesDoNotExist(...expectedFiles: string[]) {
|
||||
expectedFiles.forEach((f) => {
|
||||
const ff = f.startsWith('/') ? f : tmpProjPath(f);
|
||||
if (exists(ff)) {
|
||||
throw new Error(`File '${ff}' should not exist`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function listFiles(dirName: string) {
|
||||
return readdirSync(tmpProjPath(dirName));
|
||||
}
|
||||
|
||||
export function readJson<T extends Object = any>(f: string): T {
|
||||
const content = readFile(f);
|
||||
return parseJson<T>(content);
|
||||
}
|
||||
|
||||
export function readFile(f: string) {
|
||||
const ff = f.startsWith('/') ? f : tmpProjPath(f);
|
||||
return readFileSync(ff, 'utf-8');
|
||||
}
|
||||
|
||||
export function removeFile(f: string) {
|
||||
const ff = f.startsWith('/') ? f : tmpProjPath(f);
|
||||
removeSync(ff);
|
||||
}
|
||||
|
||||
export function rmDist() {
|
||||
removeSync(`${tmpProjPath()}/dist`);
|
||||
}
|
||||
|
||||
export function directoryExists(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isDirectory();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isFile();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function exists(filePath: string): boolean {
|
||||
return directoryExists(filePath) || fileExists(filePath);
|
||||
}
|
||||
|
||||
export function getSize(filePath: string): number {
|
||||
return statSync(filePath).size;
|
||||
}
|
||||
|
||||
export function tmpBackupProjPath(path?: string) {
|
||||
return path ? `${e2eCwd}/proj-backup/${path}` : `${e2eCwd}/proj-backup`;
|
||||
}
|
||||
|
||||
export function tmpBackupNgCliProjPath(path?: string) {
|
||||
return path
|
||||
? `${e2eCwd}/ng-cli-proj-backup/${path}`
|
||||
: `${e2eCwd}/ng-cli-proj-backup`;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { readJsonFile, workspaceRoot } from '@nx/devkit';
|
||||
import { existsSync } from 'fs-extra';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'path';
|
||||
import { gte } from 'semver';
|
||||
import { dirSync } from 'tmp';
|
||||
|
||||
import * as isCI from 'is-ci';
|
||||
import { PackageManager } from 'nx/src/utils/package-manager';
|
||||
import { tmpProjPath } from './create-project-utils';
|
||||
import { e2eConsoleLogger } from './log-utils';
|
||||
|
||||
export const isWindows = require('is-windows');
|
||||
|
||||
export function getPublishedVersion(): string {
|
||||
process.env.PUBLISHED_VERSION =
|
||||
process.env.PUBLISHED_VERSION ||
|
||||
// read version of built nx package
|
||||
readJsonFile(join(workspaceRoot, `./dist/packages/nx/package.json`))
|
||||
.version ||
|
||||
// fallback to latest if built nx package is missing
|
||||
'latest';
|
||||
return process.env.PUBLISHED_VERSION as string;
|
||||
}
|
||||
|
||||
export function detectPackageManager(dir: string = ''): PackageManager {
|
||||
return existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock'))
|
||||
? 'bun'
|
||||
: existsSync(join(dir, 'yarn.lock'))
|
||||
? 'yarn'
|
||||
: existsSync(join(dir, 'pnpm-lock.yaml')) ||
|
||||
existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||||
? 'pnpm'
|
||||
: 'npm';
|
||||
}
|
||||
|
||||
export function isNotWindows() {
|
||||
return !isWindows();
|
||||
}
|
||||
|
||||
export function isOSX() {
|
||||
return process.platform === 'darwin';
|
||||
}
|
||||
|
||||
export function isAndroid() {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
process.env.ANDROID_HOME &&
|
||||
process.env.ANDROID_SDK_ROOT
|
||||
);
|
||||
}
|
||||
|
||||
export const e2eRoot = isCI
|
||||
? dirSync({ prefix: 'nx-e2e-' }).name
|
||||
: '/tmp/nx-e2e';
|
||||
|
||||
export function isVerbose() {
|
||||
return (
|
||||
process.env.NX_VERBOSE_LOGGING === 'true' ||
|
||||
process.argv.includes('--verbose')
|
||||
);
|
||||
}
|
||||
|
||||
export function isVerboseE2ERun() {
|
||||
return process.env.NX_E2E_VERBOSE_LOGGING === 'true' || isVerbose();
|
||||
}
|
||||
|
||||
export const e2eCwd = `${e2eRoot}/nx`;
|
||||
|
||||
export function getSelectedPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' {
|
||||
return (process.env.SELECTED_PM as 'npm' | 'yarn' | 'pnpm' | 'bun') || 'npm';
|
||||
}
|
||||
|
||||
export function getNpmMajorVersion(): string | undefined {
|
||||
try {
|
||||
const [npmMajorVersion] = execSync(`npm -v`).toString().split('.');
|
||||
return npmMajorVersion;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getYarnMajorVersion(path: string): string | undefined {
|
||||
try {
|
||||
// this fails if path is not yet created
|
||||
const [yarnMajorVersion] = execSync(`yarn -v`, {
|
||||
cwd: path,
|
||||
encoding: 'utf-8',
|
||||
}).split('.');
|
||||
return yarnMajorVersion;
|
||||
} catch {
|
||||
try {
|
||||
const [yarnMajorVersion] = execSync(`yarn -v`, {
|
||||
encoding: 'utf-8',
|
||||
}).split('.');
|
||||
return yarnMajorVersion;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPnpmVersion(): string | undefined {
|
||||
try {
|
||||
const pnpmVersion = execSync(`pnpm -v`, {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
return pnpmVersion;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestLernaVersion(): string {
|
||||
const lernaVersion = execSync(`npm view lerna version`, {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
return lernaVersion;
|
||||
}
|
||||
|
||||
export const packageManagerLockFile = {
|
||||
npm: 'package-lock.json',
|
||||
yarn: 'yarn.lock',
|
||||
pnpm: 'pnpm-lock.yaml',
|
||||
bun: (() => {
|
||||
try {
|
||||
// In version 1.2.0, bun switched to a text based lockfile format by default
|
||||
return gte(execSync('bun --version').toString().trim(), '1.2.0')
|
||||
? 'bun.lock'
|
||||
: 'bun.lockb';
|
||||
} catch {
|
||||
return 'bun.lockb';
|
||||
}
|
||||
})(),
|
||||
};
|
||||
|
||||
// Re-export browser installation utilities from ensure-browser-installation.ts
|
||||
export {
|
||||
ensureCypressInstallation,
|
||||
ensurePlaywrightBrowsersInstallation,
|
||||
} from './ensure-browser-installation';
|
||||
|
||||
export function getStrippedEnvironmentVariables() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => {
|
||||
if (key.startsWith('NX_E2E_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowedKeys = [
|
||||
'NX_ADD_PLUGINS',
|
||||
'NX_ISOLATE_PLUGINS',
|
||||
'NX_VERBOSE_LOGGING',
|
||||
'NX_NATIVE_LOGGING',
|
||||
'NX_USE_LOCAL',
|
||||
];
|
||||
|
||||
if (key.startsWith('NX_') && !allowedKeys.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === 'JEST_WORKER_ID') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove NODE_PATH to prevent module resolution conflicts with original workspace.
|
||||
// NODE_PATH is inherited from Jest (which runs from the original workspace) and contains
|
||||
// pnpm paths that cause require.resolve() to find workspace packages instead of e2e test versions.
|
||||
if (key === 'NODE_PATH') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove GITHUB_STEP_SUMMARY so e2e subprocesses don't append to the real CI job
|
||||
// summary. The stripper drops NX_TASK_TARGET_PROJECT, so a child nx looks top-level
|
||||
// and would otherwise write its performance report (once per nx command) into the
|
||||
// runner's summary. GITHUB_ACTIONS is kept so log grouping still gets exercised.
|
||||
if (key === 'GITHUB_STEP_SUMMARY') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove AI agent detection env vars to prevent the test runner's
|
||||
// environment (e.g., running inside Claude Code) from leaking into
|
||||
// e2e test subprocesses. Tests that need these pass them explicitly.
|
||||
const aiAgentEnvVars = [
|
||||
'CLAUDECODE',
|
||||
'CLAUDE_CODE',
|
||||
'OPENCODE',
|
||||
'GEMINI_CLI',
|
||||
'CURSOR_TRACE_ID',
|
||||
'COMPOSER_NO_INTERACTION',
|
||||
'REPL_ID',
|
||||
];
|
||||
if (aiAgentEnvVars.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Config } from '@jest/types';
|
||||
import { existsSync, removeSync } from 'fs-extra';
|
||||
import * as isCI from 'is-ci';
|
||||
import { exec, execSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { registerTsConfigPaths } from '../../packages/nx/src/plugins/js/utils/register';
|
||||
import { runLocalRelease } from '../../scripts/local-registry/populate-storage';
|
||||
|
||||
export default async function (globalConfig: Config.ConfigGlobals) {
|
||||
try {
|
||||
// TEMP DIAGNOSTIC (cache-miss hunt): log the exact checkout + any working-tree
|
||||
// drift on the agent. Distinguishes "runs are on different SHAs" from
|
||||
// "same SHA but a task mutated the tree". Safe to remove once resolved.
|
||||
if (process.env.CI) {
|
||||
try {
|
||||
const head = execSync('git rev-parse HEAD').toString().trim();
|
||||
const porcelain = execSync('git status --porcelain').toString().trim();
|
||||
console.log(
|
||||
`\n=== [tree-state] target=${process.env.NX_TASK_TARGET_TARGET} HEAD=${head} ===\n` +
|
||||
`git status --porcelain:\n${porcelain || '(clean)'}\n`
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(
|
||||
`[tree-state] diagnostic failed: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isVerbose: boolean =
|
||||
process.env.NX_VERBOSE_LOGGING === 'true' || !!globalConfig.verbose;
|
||||
|
||||
/**
|
||||
* For e2e-ci, e2e-local and macos-local-e2e we populate the verdaccio storage up front, but for other workflows we need
|
||||
* to run the full local release process before running tests.
|
||||
*/
|
||||
const prefixes = ['e2e-ci', 'e2e-macos-local', 'e2e-local'];
|
||||
const requiresLocalRelease = !prefixes.some((prefix) =>
|
||||
process.env.NX_TASK_TARGET_TARGET?.startsWith(prefix)
|
||||
);
|
||||
|
||||
const listenAddress = 'localhost';
|
||||
const port = process.env.NX_LOCAL_REGISTRY_PORT ?? '4873';
|
||||
const registry = `http://${listenAddress}:${port}`;
|
||||
const authToken = 'secretVerdaccioToken';
|
||||
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
try {
|
||||
await assertLocalRegistryIsRunning(registry);
|
||||
break;
|
||||
} catch {
|
||||
console.log(`Waiting for Local registry to start on ${registry}...`);
|
||||
}
|
||||
}
|
||||
|
||||
process.env.npm_config_registry = registry;
|
||||
// Use environment variable instead of npm config command to avoid polluting other tests
|
||||
process.env[`npm_config_//${listenAddress}:${port}/:_authToken`] =
|
||||
authToken;
|
||||
|
||||
// bun
|
||||
process.env.BUN_CONFIG_REGISTRY = registry;
|
||||
process.env.BUN_CONFIG_TOKEN = authToken;
|
||||
// yarnv1
|
||||
process.env.YARN_REGISTRY = registry;
|
||||
// yarnv2
|
||||
process.env.YARN_NPM_REGISTRY_SERVER = registry;
|
||||
process.env.YARN_UNSAFE_HTTP_WHITELIST = listenAddress;
|
||||
|
||||
// Use fresh cache directories to avoid serving stale packages when the
|
||||
// same version is republished to the local registry.
|
||||
const e2eCacheDir = mkdtempSync(join(tmpdir(), 'nx-e2e-cache-'));
|
||||
process.env.npm_config_cache = join(e2eCacheDir, 'npm');
|
||||
// yarnv1
|
||||
process.env.YARN_CACHE_FOLDER = join(e2eCacheDir, 'yarn');
|
||||
// yarnv2
|
||||
process.env.YARN_ENABLE_GLOBAL_CACHE = 'false';
|
||||
|
||||
process.env.NX_SKIP_PROVENANCE_CHECK = 'true';
|
||||
|
||||
global.e2eTeardown = () => {
|
||||
// Clean up environment variable instead of npm config command
|
||||
delete process.env[`npm_config_//${listenAddress}:${port}/:_authToken`];
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the published version based on what has previously been loaded into the
|
||||
* verdaccio storage.
|
||||
*/
|
||||
if (!requiresLocalRelease) {
|
||||
let publishedVersion = await getPublishedVersion();
|
||||
console.log(`Testing Published version: Nx ${publishedVersion}`);
|
||||
if (publishedVersion) {
|
||||
process.env.PUBLISHED_VERSION = publishedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NX_E2E_SKIP_GLOBAL_CLEANUP !== 'true' ||
|
||||
!existsSync('./build')
|
||||
) {
|
||||
if (!isCI) {
|
||||
registerTsConfigPaths(join(__dirname, '../../tsconfig.base.json'));
|
||||
const { e2eCwd } = await import('./get-env-info');
|
||||
removeSync(e2eCwd);
|
||||
}
|
||||
if (requiresLocalRelease) {
|
||||
console.log('Publishing packages to local registry');
|
||||
const publishVersion = process.env.PUBLISHED_VERSION ?? 'major';
|
||||
// Always show full release logs on CI, they should only happen once via e2e-ci
|
||||
await runLocalRelease(publishVersion, isCI || isVerbose);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Clean up registry if possible after setup related errors
|
||||
if (typeof global.e2eTeardown === 'function') {
|
||||
global.e2eTeardown();
|
||||
console.log('Killed local registry process due to an error during setup');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getPublishedVersion(): Promise<string | undefined> {
|
||||
execSync(`npm config get registry`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
// Resolve the published nx version from verdaccio
|
||||
exec(
|
||||
'npm view nx@latest version',
|
||||
{
|
||||
windowsHide: false,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
return resolve(undefined);
|
||||
}
|
||||
return resolve(stdout.trim());
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertLocalRegistryIsRunning(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default function () {
|
||||
if (global.e2eTeardown) {
|
||||
global.e2eTeardown();
|
||||
console.log('Killed local registry process');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { e2eCwd } from './get-env-info';
|
||||
|
||||
ensureDirSync(e2eCwd);
|
||||
|
||||
export * from './command-utils';
|
||||
export * from './create-project-utils';
|
||||
export * from './file-utils';
|
||||
export * from './get-env-info';
|
||||
export * from './log-utils';
|
||||
export * from './test-utils';
|
||||
export * from './process-utils';
|
||||
export * from './strip-indents';
|
||||
export * from './port-utils';
|
||||
@@ -0,0 +1,70 @@
|
||||
import { styleText } from 'node:util';
|
||||
|
||||
const orangeColor = (text) => `\x1b[38;5;214m${text}\x1b[39m`;
|
||||
|
||||
export const E2E_LOG_PREFIX = `${styleText(
|
||||
['reset', 'inverse', 'bold'],
|
||||
orangeColor(' E2E ')
|
||||
)}`;
|
||||
|
||||
export function e2eConsoleLogger(message: string, body?: string) {
|
||||
process.stdout.write('\n');
|
||||
process.stdout.write(`${E2E_LOG_PREFIX} ${message}\n`);
|
||||
if (body) {
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
export function logInfo(title: string, body?: string) {
|
||||
const message = `${styleText(
|
||||
['reset', 'inverse', 'bold', 'white'],
|
||||
' INFO '
|
||||
)} ${styleText(['bold', 'white'], title)}`;
|
||||
return e2eConsoleLogger(message, body);
|
||||
}
|
||||
|
||||
export function logError(title: string, body?: string) {
|
||||
const message = `${styleText(
|
||||
['reset', 'inverse', 'bold', 'red'],
|
||||
' ERROR '
|
||||
)} ${styleText(['bold', 'red'], title)}`;
|
||||
return e2eConsoleLogger(message, body);
|
||||
}
|
||||
|
||||
export function logSuccess(title: string, body?: string) {
|
||||
const message = `${styleText(
|
||||
['reset', 'inverse', 'bold', 'green'],
|
||||
' SUCCESS '
|
||||
)} ${styleText(['bold', 'green'], title)}`;
|
||||
return e2eConsoleLogger(message, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims a daemon.log to the lines useful for diagnosing daemon/plugin issues
|
||||
* (restarts, plugin loads, stale-graph discards, errors) plus the tail,
|
||||
* marking where noise was dropped. Keeps e2e CI output small but debuggable.
|
||||
*/
|
||||
export function trimDaemonLog(contents: string, tailLines = 30): string {
|
||||
const signal =
|
||||
/New daemon|Server stopped|Restarting daemon|Started new daemon|Daemon outdated|lock file hash|loadSpecifiedNxPlugins|loadDefaultNxPlugins|Load Nx Plugin:|Failed to load|AggregateError|Unable to find local plugin|Discarding stale|No in-memory cached project graph|Graph recompute necessary|recomputing project graph|Cannot find module|with an error|Error:/;
|
||||
const lines = contents.split('\n');
|
||||
const tailStart = Math.max(0, lines.length - tailLines);
|
||||
const out: string[] = [];
|
||||
let dropped = 0;
|
||||
lines.forEach((line, i) => {
|
||||
if (signal.test(line) || i >= tailStart) {
|
||||
if (dropped > 0) {
|
||||
out.push(` … ${dropped} line(s) trimmed …`);
|
||||
dropped = 0;
|
||||
}
|
||||
out.push(line);
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
});
|
||||
if (dropped > 0) {
|
||||
out.push(` … ${dropped} line(s) trimmed …`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@nx/e2e-utils",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"dependencies": {
|
||||
"nx": "workspace:*",
|
||||
"@nx/devkit": "workspace:*",
|
||||
"@nx/angular": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Reserves a port across parallel processes on the same host via an atomic
|
||||
* lock file. Avoids the TOCTOU race of probing port 0 and then binding it
|
||||
* seconds later — another parallel process could be handed the same port in
|
||||
* between.
|
||||
*
|
||||
* Starts at 6100 (outside the framework-default zone of 3000/4200/5173/8080/etc.)
|
||||
* so reserved ports never collide with parallel tests that generate apps
|
||||
* without explicitly pinning the dev-server port.
|
||||
*
|
||||
* Caveat: this reserves the port *number* against other `reservePort()`
|
||||
* callers; it does not hold the OS socket open. A consumer that binds the
|
||||
* port much later (e.g. after a long webpack compile) is still racing
|
||||
* anything that does not participate in this scheme.
|
||||
*/
|
||||
const LOCK_DIR = path.join(os.tmpdir(), 'nx-e2e-port-locks');
|
||||
fs.mkdirSync(LOCK_DIR, { recursive: true });
|
||||
|
||||
const RANGE_FLOOR = 6100;
|
||||
const RANGE_CEILING = 65000;
|
||||
// Spread of the randomised scan origin (see reservePort). Wide enough that
|
||||
// reservations scatter thinly across nearly the whole range, so a squatter
|
||||
// (a leaked dev server, or anything binding a port mid-compile) is unlikely
|
||||
// to land on the specific port a test reserved.
|
||||
const SCAN_SPREAD = 55000;
|
||||
// A lock older than this is treated as abandoned even when its PID still
|
||||
// resolves (the PID may have been recycled). Comfortably above the longest
|
||||
// e2e test timeout so a legitimately long-held port is never stolen.
|
||||
const STALE_LOCK_MS = 60 * 60 * 1000;
|
||||
// Whether to reclaim abandoned lock files. In CI each agent is an ephemeral
|
||||
// container, so stale locks cannot carry across runs; combined with the wide
|
||||
// scan spread, the few a timeout-killed task may leave are immaterial. An
|
||||
// existing lock is simply treated as taken. Reclamation only earns its keep
|
||||
// on a developer's machine, where /tmp persists across runs.
|
||||
const RECLAIM_ABANDONED_LOCKS = !process.env.CI;
|
||||
|
||||
// Lock files held by THIS process. A single exit handler frees them all,
|
||||
// rather than registering one listener per reservePort() call.
|
||||
const heldLocks = new Set<string>();
|
||||
process.once('exit', () => {
|
||||
for (const lock of heldLocks) {
|
||||
try {
|
||||
fs.unlinkSync(lock);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function lockPath(port: number): string {
|
||||
return path.join(LOCK_DIR, `${port}.lock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lock is abandoned if its owning process is gone, or it is older than
|
||||
* STALE_LOCK_MS. A SIGKILLed e2e process never runs its exit handler, so
|
||||
* without this its locks would block those ports for every later run.
|
||||
*/
|
||||
function isAbandonedLock(lock: string): boolean {
|
||||
let stat: fs.Stats;
|
||||
let pid: number;
|
||||
try {
|
||||
stat = fs.statSync(lock);
|
||||
pid = parseInt(fs.readFileSync(lock, 'utf8').trim(), 10);
|
||||
} catch {
|
||||
return true; // vanished between checks — treat as free
|
||||
}
|
||||
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) return true;
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0); // signal 0: existence check, does not kill
|
||||
return false; // owner still alive
|
||||
} catch (err) {
|
||||
// ESRCH: no such process → abandoned. EPERM: exists, not ours → alive.
|
||||
return (err as NodeJS.ErrnoException).code === 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically claim the lock for `port`; returns true on success. */
|
||||
function claimLock(port: number): boolean {
|
||||
const lock = lockPath(port);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
||||
heldLocks.add(lock);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||
}
|
||||
// Lock exists. Outside CI, reclaim it once if abandoned then retry the
|
||||
// claim; in CI an existing lock is simply treated as taken.
|
||||
if (!RECLAIM_ABANDONED_LOCKS || !isAbandonedLock(lock)) return false;
|
||||
try {
|
||||
fs.unlinkSync(lock);
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function releaseLock(port: number): void {
|
||||
const lock = lockPath(port);
|
||||
heldLocks.delete(lock);
|
||||
try {
|
||||
fs.unlinkSync(lock);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function reservePort(start = RANGE_FLOOR): Promise<number> {
|
||||
// Randomise the scan origin so parallel e2e processes spread across the
|
||||
// range instead of all converging on — and fighting over — the low ports.
|
||||
// The scan wraps around so the whole [start, RANGE_CEILING) range is covered
|
||||
// even when the origin lands high; otherwise the ports below it are skipped
|
||||
// and we could throw spuriously while ports are still free.
|
||||
const span = RANGE_CEILING - start;
|
||||
const origin = Math.floor(Math.random() * Math.min(SCAN_SPREAD, span));
|
||||
for (let i = 0; i < span; i++) {
|
||||
const port = start + ((origin + i) % span);
|
||||
if (!claimLock(port)) continue;
|
||||
// Lock claimed; verify the OS port is actually free. Another e2e test on
|
||||
// the same agent may be using it via a generator default (i.e. without
|
||||
// participating in the lock scheme), so an exclusive lock is not enough.
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
releaseLock(port);
|
||||
}
|
||||
throw new Error('No available ports');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves `count` *consecutive* ports and returns them in ascending order.
|
||||
*
|
||||
* The contiguity matters: module federation e2e tests pass `ports[0]` as the
|
||||
* host's `--devServerPort`, and the generator then wires the host to its
|
||||
* remotes at `ports[0] + 1`, `+ 2`, ... — so the reserved ports must be a
|
||||
* contiguous run for the host and remotes to line up.
|
||||
*/
|
||||
export async function reservePorts(count: number): Promise<number[]> {
|
||||
if (count <= 1) {
|
||||
return count === 1 ? [await reservePort()] : [];
|
||||
}
|
||||
// Randomise the scan origin (same rationale as reservePort) and wrap around
|
||||
// so every valid window start is tried, then return the first window of
|
||||
// `count` consecutive ports that are all claimable and actually free.
|
||||
const lastStart = RANGE_CEILING - count;
|
||||
const numStarts = lastStart - RANGE_FLOOR + 1;
|
||||
const origin = Math.floor(Math.random() * Math.min(SCAN_SPREAD, numStarts));
|
||||
for (let i = 0; i < numStarts; i++) {
|
||||
const start = RANGE_FLOOR + ((origin + i) % numStarts);
|
||||
const claimed: number[] = [];
|
||||
for (let port = start; port < start + count; port++) {
|
||||
if (!claimLock(port)) break;
|
||||
if (!(await isPortAvailable(port))) {
|
||||
releaseLock(port);
|
||||
break;
|
||||
}
|
||||
claimed.push(port);
|
||||
}
|
||||
if (claimed.length === count) {
|
||||
return claimed;
|
||||
}
|
||||
// Window failed partway — release whatever we managed to claim in it.
|
||||
for (const port of claimed) {
|
||||
releaseLock(port);
|
||||
}
|
||||
}
|
||||
throw new Error(`No available run of ${count} consecutive ports`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link reservePort} — probing the OS for port 0 opens a
|
||||
* TOCTOU race across parallel e2e processes. Kept for backwards compatibility.
|
||||
*/
|
||||
export async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
|
||||
server.listen(0, () => {
|
||||
const addressInfo = server.address();
|
||||
if (!addressInfo || typeof addressInfo === 'string') {
|
||||
reject(new Error('Failed to get server address'));
|
||||
return;
|
||||
}
|
||||
const port = addressInfo.port;
|
||||
server.close(() => {
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link reservePorts}.
|
||||
*/
|
||||
export async function getAvailablePorts(count: number): Promise<number[]> {
|
||||
const ports: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const port = await getAvailablePort();
|
||||
ports.push(port);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific port is available
|
||||
*
|
||||
* @param port - Port number to check
|
||||
* @returns Promise<boolean> - True if port is available, false otherwise
|
||||
*/
|
||||
export async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { promisify } from 'util';
|
||||
import treeKill = require('tree-kill');
|
||||
import { logError, logInfo, logSuccess } from './log-utils';
|
||||
import { check as portCheck } from 'tcp-port-used';
|
||||
|
||||
export const kill = require('kill-port');
|
||||
const KILL_PORT_DELAY = 5000;
|
||||
|
||||
export const promisifiedTreeKill: (
|
||||
pid: number,
|
||||
signal: string
|
||||
) => Promise<void> = promisify(treeKill);
|
||||
|
||||
export async function killPort(port: number): Promise<boolean> {
|
||||
let inUse: boolean;
|
||||
try {
|
||||
inUse = await portCheck(port);
|
||||
} catch {
|
||||
// tcp-port-used's check() rejects on any connect error other than
|
||||
// ECONNREFUSED. A port whose process was just killed can reset the probe
|
||||
// (ECONNRESET) instead of cleanly refusing it; treat "can't probe" as
|
||||
// freed rather than letting it throw and fail the caller's teardown.
|
||||
return true;
|
||||
}
|
||||
if (inUse) {
|
||||
let killPortResult;
|
||||
try {
|
||||
logInfo(`Attempting to close port ${port}`);
|
||||
killPortResult = await kill(port);
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(() => resolve(), KILL_PORT_DELAY)
|
||||
);
|
||||
if (await portCheck(port)) {
|
||||
logError(`Port ${port} still open`, JSON.stringify(killPortResult));
|
||||
} else {
|
||||
logSuccess(`Port ${port} successfully closed`);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
logError(`Port ${port} closing failed`);
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function killPorts(port?: number): Promise<boolean> {
|
||||
return port
|
||||
? await killPort(port)
|
||||
: (await killPort(3333)) && (await killPort(4200));
|
||||
}
|
||||
|
||||
export async function killProcessAndPorts(
|
||||
pid: number | undefined,
|
||||
...ports: number[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (pid) {
|
||||
await promisifiedTreeKill(pid, 'SIGKILL');
|
||||
}
|
||||
for (const port of ports) {
|
||||
await killPort(port);
|
||||
}
|
||||
} catch (err) {
|
||||
expect(err).toBeFalsy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "e2e-utils",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "e2e/utils",
|
||||
"projectType": "library",
|
||||
"// targets": "to see all targets run: nx show project e2e-utils --web",
|
||||
"targets": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function stripIndents(
|
||||
strings: TemplateStringsArray,
|
||||
...values: any[]
|
||||
): string {
|
||||
return String.raw(strings, ...values)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
getPackageManagerCommand,
|
||||
runCLI,
|
||||
runCLIAsync,
|
||||
runCommand,
|
||||
} from './command-utils';
|
||||
import { uniq } from './create-project-utils';
|
||||
import { tmpProjPath } from './create-project-utils';
|
||||
import { readFile, fileExists, updateFile } from './file-utils';
|
||||
|
||||
type GeneratorsWithDefaultTests =
|
||||
| '@nx/js:lib'
|
||||
| '@nx/node:lib'
|
||||
| '@nx/react:lib'
|
||||
| '@nx/react:app'
|
||||
| '@nx/next:app'
|
||||
| '@nx/angular:app'
|
||||
| '@nx/web:app';
|
||||
|
||||
/**
|
||||
* Runs the pass in generator and then runs test on
|
||||
* the generated project to make sure the default tests pass.
|
||||
*/
|
||||
export async function expectJestTestsToPass(
|
||||
generator: GeneratorsWithDefaultTests | string
|
||||
) {
|
||||
const name = uniq('proj');
|
||||
const generatedResults = runCLI(
|
||||
`generate ${generator} ${name} --no-interactive`
|
||||
);
|
||||
expect(generatedResults).toContain(`jest.config.ts`);
|
||||
|
||||
const results = await runCLIAsync(`test ${name}`);
|
||||
expect(results.combinedOutput).toContain('Test Suites: 1 passed, 1 total');
|
||||
}
|
||||
|
||||
export function expectTestsPass(v: { stdout: string; stderr: string }) {
|
||||
expect(v.stderr).toContain('Ran all test suites');
|
||||
expect(v.stderr).not.toContain('fail');
|
||||
}
|
||||
|
||||
// TODO(meeroslav): This is test specific, it should not be in the utils
|
||||
export function expectNoAngularDevkit() {
|
||||
const { list } = getPackageManagerCommand();
|
||||
const result = runCommand(`${list} @angular-devkit/core`);
|
||||
expect(result).not.toContain('@angular-devkit/core');
|
||||
}
|
||||
|
||||
// TODO(meeroslav): This is test specific, it should not be in the utils
|
||||
export function expectNoTsJestInJestConfig(appName: string) {
|
||||
const candidates = [
|
||||
tmpProjPath(join('apps', appName, 'jest.config.js')),
|
||||
tmpProjPath(join('apps', appName, 'jest.config.ts')),
|
||||
tmpProjPath(join('apps', appName, 'jest.config.cts')),
|
||||
];
|
||||
let jestConfig: string;
|
||||
for (const c of candidates) {
|
||||
if (fileExists(c)) {
|
||||
jestConfig = readFile(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jestConfig) {
|
||||
throw new Error(`Could not find jest config for app/lib: ${appName}`);
|
||||
}
|
||||
expect(jestConfig).not.toContain('ts-jest');
|
||||
}
|
||||
|
||||
export function expectCodeIsFormatted() {
|
||||
expect(() => runCLI(`format:check`)).not.toThrow();
|
||||
}
|
||||
|
||||
export function setCypressWebServerTimeout(
|
||||
cypressConfigPath: string,
|
||||
timeout = 60_000
|
||||
) {
|
||||
const cypressConfig = readFile(cypressConfigPath);
|
||||
updateFile(
|
||||
cypressConfigPath,
|
||||
cypressConfig.replace(
|
||||
`nxE2EPreset(__filename, {`,
|
||||
`nxE2EPreset(__filename, {
|
||||
webServerConfig: {
|
||||
timeout: ${timeout},
|
||||
},`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
],
|
||||
"nx": {
|
||||
"typecheckTargetName": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false,
|
||||
"strict": false
|
||||
},
|
||||
"exclude": [
|
||||
"out-tsc",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"jest.config.ts",
|
||||
"global-setup.ts",
|
||||
"global-teardown.ts"
|
||||
],
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/angular/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/devkit/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/nx/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user