Skip to content

Commit

Permalink
feat(core): error when running atomized tasks outside of DTE (#26898)
Browse files Browse the repository at this point in the history
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
Nx allows running atomized tasks (like `e2e-ci`) anywhere.

## Expected Behavior
Nx allows running atomized tasks (like `e2e-ci`) only in distributed
environments like Nx Agents or DTE. We'll leave that actual check to nx
cloud but in the default runner, we'll throw an error.
It's possible to escape from this with an env var.
  • Loading branch information
MaxKless authored Jul 23, 2024
1 parent e9b02cb commit 184e83a
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 27 deletions.
11 changes: 9 additions & 2 deletions docs/nx-cloud/features/split-e2e-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ width="100%" /%}

In almost every codebase, e2e tests are the largest portion of the CI pipeline. Typically, e2e tests are grouped by application so that whenever an application's code changes, all the e2e tests for that application are run. These large groupings of e2e tests make caching and distribution less effective. Also, because e2e tests deal with a lot of integration code, they are at a much higher risk to be flaky.

You could manually address these problems by splitting your e2e tests into smaller tasks, but this requires developer time to maintain and adds additional configuration overhead to your codebase. Or, you could allow Nx to automatically split your Cypress or Playwright e2e tests by file.
You could manually address these problems by splitting your e2e tests into smaller tasks, but this requires developer time to maintain and adds additional configuration overhead to your codebase. Or, you could allow Nx to automatically split your e2e tests by file. Doing this will split your large e2e tasks into smaller, atomized tasks.

## Set up

To enable automatically split e2e tasks, you need to turn on [inferred tasks](/concepts/inferred-tasks#existing-nx-workspaces) for the [@nx/cypress](/nx-api/cypress), [@nx/playwright](/nx-api/playwright), or [@nx/jest](/nx-api/jest) plugins. Run this command to set up inferred tasks:
To enable atomized tasks, you need to turn on [inferred tasks](/concepts/inferred-tasks#existing-nx-workspaces) for the [@nx/cypress](/nx-api/cypress), [@nx/playwright](/nx-api/playwright), [@nx/jest](/nx-api/jest) or [@nx/gradle](/nx-api/gradle) plugins. Run this command to set up inferred tasks:

{% tabs %}
{% tab label="Cypress" %}
Expand Down Expand Up @@ -394,3 +394,10 @@ With more granular e2e tasks, all the other features of Nx become more powerful.
### More Precise Flaky Task Identification

Nx Agents [automatically re-run failed flaky e2e tests](/ci/features/flaky-tasks) on a separate agent without a developer needing to manually re-run the CI pipeline. Leveraging e2e task splitting, Nx identifies the specific flaky test file - this way you can quickly fix the offending test file. Without e2e splitting, Nx identifies that at least one of the e2e tests are flaky - requiring you to find the flaky test on your own.

## Nx Cloud is Required to Run Atomized Tasks!

Running a group of atomized tasks on a single machine takes longer than running the large e2e task.
Nx Cloud [distributes](/ci/features/distribute-task-execution) the atomized tasks across multiple Nx agents in parallel.

When running locally or if you cannot use Nx Agents, it's better to use the non-atomized target instead (`e2e` in the example above).
8 changes: 7 additions & 1 deletion e2e/jest/src/jest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { stripIndents } from '@angular-devkit/core/src/utils/literals';
import {
cleanupProject,
expectJestTestsToPass,
getStrippedEnvironmentVariables,
newProject,
runCLI,
runCLIAsync,
Expand Down Expand Up @@ -161,6 +162,11 @@ describe('Jest', () => {
return json;
});

await runCLIAsync(`e2e-ci ${libName}`);
await runCLIAsync(`e2e-ci ${libName}`, {
env: {
...getStrippedEnvironmentVariables(),
NX_SKIP_ATOMIZER_VALIDATION: 'true',
},
});
}, 90000);
});
5 changes: 5 additions & 0 deletions e2e/next/src/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
checkFilesDoNotExist,
checkFilesExist,
cleanupProject,
getStrippedEnvironmentVariables,
newProject,
readFile,
runCLI,
Expand Down Expand Up @@ -195,6 +196,10 @@ describe('Next.js Applications', () => {
if (runE2ETests('cypress')) {
const e2eResults = runCLI(`e2e-ci ${appName}-e2e --verbose`, {
verbose: true,
env: {
...getStrippedEnvironmentVariables(),
NX_SKIP_ATOMIZER_VALIDATION: 'true',
},
});
expect(e2eResults).toContain(
'Successfully ran target e2e-ci for project'
Expand Down
58 changes: 35 additions & 23 deletions packages/nx/src/tasks-runner/run-command.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
import { TasksRunner, TaskStatus } from './tasks-runner';
import { join } from 'path';
import { workspaceRoot } from '../utils/workspace-root';
import { NxArgs } from '../utils/command-line-utils';
import { isRelativePath } from '../utils/fileutils';
import { output } from '../utils/output';
import { shouldStreamOutput } from './utils';
import { CompositeLifeCycle, LifeCycle } from './life-cycle';
import { StaticRunManyTerminalOutputLifeCycle } from './life-cycles/static-run-many-terminal-output-life-cycle';
import { StaticRunOneTerminalOutputLifeCycle } from './life-cycles/static-run-one-terminal-output-life-cycle';
import { TaskTimingsLifeCycle } from './life-cycles/task-timings-life-cycle';
import { createRunManyDynamicOutputRenderer } from './life-cycles/dynamic-run-many-terminal-output-life-cycle';
import { TaskProfilingLifeCycle } from './life-cycles/task-profiling-life-cycle';
import { isCI } from '../utils/is-ci';
import { createRunOneDynamicOutputRenderer } from './life-cycles/dynamic-run-one-terminal-output-life-cycle';
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
import {
NxJsonConfiguration,
readNxJson,
TargetDefaults,
TargetDependencies,
} from '../config/nx-json';
import { ProjectGraph, ProjectGraphProjectNode } from '../config/project-graph';
import { Task, TaskGraph } from '../config/task-graph';
import { createTaskGraph } from './create-task-graph';
import { findCycle, makeAcyclic } from './task-graph-utils';
import { TargetDependencyConfig } from '../config/workspace-json-project-json';
import { handleErrors } from '../utils/params';
import { hashTasksThatDoNotDependOnOutputsOfOtherTasks } from '../hasher/hash-task';
import { daemonClient } from '../daemon/client/client';
import { StoreRunInformationLifeCycle } from './life-cycles/store-run-information-life-cycle';
import { createTaskHasher } from '../hasher/create-task-hasher';
import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle';
import { hashTasksThatDoNotDependOnOutputsOfOtherTasks } from '../hasher/hash-task';
import { NxArgs } from '../utils/command-line-utils';
import { isRelativePath } from '../utils/fileutils';
import { isCI } from '../utils/is-ci';
import { isNxCloudUsed } from '../utils/nx-cloud-utils';
import { output } from '../utils/output';
import { handleErrors } from '../utils/params';
import { workspaceRoot } from '../utils/workspace-root';
import { createTaskGraph } from './create-task-graph';
import { CompositeLifeCycle, LifeCycle } from './life-cycle';
import { createRunManyDynamicOutputRenderer } from './life-cycles/dynamic-run-many-terminal-output-life-cycle';
import { createRunOneDynamicOutputRenderer } from './life-cycles/dynamic-run-one-terminal-output-life-cycle';
import { StaticRunManyTerminalOutputLifeCycle } from './life-cycles/static-run-many-terminal-output-life-cycle';
import { StaticRunOneTerminalOutputLifeCycle } from './life-cycles/static-run-one-terminal-output-life-cycle';
import { StoreRunInformationLifeCycle } from './life-cycles/store-run-information-life-cycle';
import { TaskHistoryLifeCycle } from './life-cycles/task-history-life-cycle';
import { TaskProfilingLifeCycle } from './life-cycles/task-profiling-life-cycle';
import { TaskTimingsLifeCycle } from './life-cycles/task-timings-life-cycle';
import {
findCycle,
makeAcyclic,
validateNoAtomizedTasks,
} from './task-graph-utils';
import { TasksRunner, TaskStatus } from './tasks-runner';
import { shouldStreamOutput } from './utils';

async function getTerminalOutputLifeCycle(
initiatingProject: string,
Expand Down Expand Up @@ -91,7 +95,7 @@ async function getTerminalOutputLifeCycle(
}
}

function createTaskGraphAndValidateCycles(
function createTaskGraphAndRunValidations(
projectGraph: ProjectGraph,
extraTargetDependencies: TargetDependencies,
projectNames: string[],
Expand Down Expand Up @@ -129,6 +133,14 @@ function createTaskGraphAndValidateCycles(
}
}

// validate that no atomized tasks like e2e-ci are used without Nx Cloud
if (
!isNxCloudUsed(readNxJson()) &&
!process.env['NX_SKIP_ATOMIZER_VALIDATION']
) {
validateNoAtomizedTasks(taskGraph, projectGraph);
}

return taskGraph;
}

Expand All @@ -147,7 +159,7 @@ export async function runCommand(
async () => {
const projectNames = projectsToRun.map((t) => t.name);

const taskGraph = createTaskGraphAndValidateCycles(
const taskGraph = createTaskGraphAndRunValidations(
projectGraph,
extraTargetDependencies ?? {},
projectNames,
Expand Down
145 changes: 144 additions & 1 deletion packages/nx/src/tasks-runner/task-graph-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { findCycle, makeAcyclic } from './task-graph-utils';
import '../internal-testing-utils/mock-fs';

import { vol } from 'memfs';

import { join } from 'path';
import {
findCycle,
makeAcyclic,
validateNoAtomizedTasks,
} from './task-graph-utils';
import { ensureDirSync, removeSync, writeFileSync } from 'fs-extra';
import { tmpdir } from 'os';
import { workspaceRoot } from '../utils/workspace-root';
import { cacheDir } from '../utils/cache-directory';
import { Task } from '../config/task-graph';
import { ProjectGraph } from '../config/project-graph';

describe('task graph utils', () => {
describe('findCycles', () => {
Expand Down Expand Up @@ -57,4 +72,132 @@ describe('task graph utils', () => {
expect(graph.roots).toEqual(['d', 'e']);
});
});

describe('validateNoAtomizedTasks', () => {
let mockProcessExit: jest.SpyInstance;
let env: NodeJS.ProcessEnv;

beforeEach(() => {
env = process.env;
process.env = {};

mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementation((code: number) => {
return undefined as any as never;
});
});

afterEach(() => {
process.env = env;
vol.reset();
mockProcessExit.mockRestore();
});

it('should do nothing if no tasks are atomized', () => {
const taskGraph = {
tasks: {
'e2e:e2e': {
id: 'e2e:e2e',
target: {
project: 'e2e',
target: 'e2e',
},
},
},
};
const projectGraph = {
nodes: {
e2e: {
data: {
targets: {
e2e: {},
},
},
},
},
};
validateNoAtomizedTasks(taskGraph as any, projectGraph as any);
expect(mockProcessExit).not.toHaveBeenCalled();
});

it('should exit if atomized task is present', () => {
const taskGraph = {
tasks: {
'e2e:e2e-ci': {
id: 'e2e:e2e-ci',
target: {
project: 'e2e',
target: 'e2e-ci',
},
},
},
};
const projectGraph = {
nodes: {
e2e: {
data: {
targets: {
'e2e-ci': {
metadata: {
nonAtomizedTarget: 'e2e',
},
},
},
},
},
},
};
validateNoAtomizedTasks(taskGraph as any, projectGraph as any);
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('should exit if multiple atomized tasks are present', () => {
const taskGraph = {
tasks: {
'e2e:e2e-ci': {
id: 'e2e:e2e-ci',
target: {
project: 'e2e',
target: 'e2e-ci',
},
},
'gradle:test-ci': {
id: 'gradle:test-ci',
target: {
project: 'gradle',
target: 'test-ci',
},
},
},
};
const projectGraph = {
nodes: {
e2e: {
data: {
targets: {
'e2e-ci': {
metadata: {
nonAtomizedTarget: 'e2e',
},
},
},
},
},
gradle: {
data: {
targets: {
'test-ci': {
metadata: {
nonAtomizedTarget: 'test',
},
},
},
},
},
},
};
validateNoAtomizedTasks(taskGraph as any, projectGraph as any);
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
});
});
53 changes: 53 additions & 0 deletions packages/nx/src/tasks-runner/task-graph-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { readNxJson } from '../config/configuration';
import { ProjectGraph } from '../config/project-graph';
import { Task, TaskGraph } from '../config/task-graph';
import { isNxCloudUsed } from '../utils/nx-cloud-utils';
import { output } from '../utils/output';
import { serializeTarget } from '../utils/serialize-target';
import chalk = require('chalk');

function _findCycle(
graph: { dependencies: Record<string, string[]> },
id: string,
Expand Down Expand Up @@ -66,3 +74,48 @@ export function makeAcyclic(graph: {
(t) => graph.dependencies[t].length === 0
);
}

export function validateNoAtomizedTasks(
taskGraph: TaskGraph,
projectGraph: ProjectGraph
) {
const getNonAtomizedTargetForTask = (task) =>
projectGraph.nodes[task.target.project]?.data?.targets?.[task.target.target]
?.metadata?.nonAtomizedTarget;

const atomizedRootTasks = Object.values(taskGraph.tasks).filter(
(task) => getNonAtomizedTargetForTask(task) !== undefined
);

if (atomizedRootTasks.length === 0) {
return;
}

const nonAtomizedTasks = atomizedRootTasks
.map((t) => `"${getNonAtomizedTargetForTask(t)}"`)
.filter((item, index, arr) => arr.indexOf(item) === index);

const moreInfoLines = [
`Please enable Nx Cloud or use the slower ${nonAtomizedTasks.join(
','
)} task${nonAtomizedTasks.length > 1 ? 's' : ''}.`,
'Learn more at https://nx.dev/ci/features/split-e2e-tasks#use-atomizer-only-with-nx-cloud-distribution',
];

if (atomizedRootTasks.length === 1) {
output.error({
title: `The ${atomizedRootTasks[0].id} task should only be run with Nx Cloud.`,
bodyLines: [...moreInfoLines],
});
} else {
output.error({
title: `The following tasks should only be run with Nx Cloud:`,
bodyLines: [
...atomizedRootTasks.map((task) => ` - ${task.id}`),
'',
...moreInfoLines,
],
});
}
process.exit(1);
}

0 comments on commit 184e83a

Please sign in to comment.