Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions e2e/nx/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,13 @@ describe('Nx Running Tests', () => {
expect(stdout).toMatch(/ECHOED positional --a=123 --no-b/);
}

expect(
runCLI(`echo:fail ${mylib}`, {
silenceError: true,
})
).toContain(`Cannot find configuration for task ${mylib}:echo:fail`);
const echoFailOutput = runCLI(`echo:fail ${mylib}`, {
silenceError: true,
});
// The target does not exist, so run-one reports the available targets
// (the only included script) instead of a cryptic task graph error.
expect(echoFailOutput).toContain(`Cannot find target "echo:fail"`);
expect(echoFailOutput).toContain(`echo:dev`);

updateJson(`libs/${mylib}/project.json`, (c) => original);
}, 1000000);
Expand Down
159 changes: 158 additions & 1 deletion packages/nx/src/command-line/run/run-one.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ProjectGraph } from '../../config/project-graph';
import { NxJsonConfiguration } from '../../config/nx-json';
import { parseRunOneOptions } from './run-one';
import {
getCannotFindProjectError,
getRunOneTargetError,
parseRunOneOptions,
} from './run-one';

describe('parseRunOneOptions', () => {
let projectGraph: ProjectGraph;
Expand Down Expand Up @@ -44,6 +48,16 @@ describe('parseRunOneOptions', () => {
},
},
},
'colon-proj': {
name: 'colon-proj',
type: 'lib',
data: {
root: 'libs/colon-proj',
targets: {
'zzcustom:variant': { executor: 'nx:run-commands' },
},
},
},
},
dependencies: {},
};
Expand Down Expand Up @@ -476,6 +490,149 @@ describe('parseRunOneOptions', () => {
});
});

describe('getRunOneTargetError', () => {
it('should return null when the target exists on the project', () => {
expect(
getRunOneTargetError(projectGraph.nodes['my-app'], 'build')
).toBeNull();
});

it('should list available targets and suggest the closest one for a typo', () => {
const error = getRunOneTargetError(
projectGraph.nodes['my-app'],
'biuld'
)!;

expect(error.title).toBe(
'Cannot find target "biuld" for project "my-app"'
);
expect(error.bodyLines).toContain('Did you mean "build"?');
expect(error.bodyLines).toContain(' - build');
expect(error.bodyLines).toContain(' - serve');
expect(error.bodyLines).toContain(' - run');
});

it('should omit the suggestion when no target is close enough', () => {
const error = getRunOneTargetError(
projectGraph.nodes['my-app'],
'nonsense'
)!;

expect(
error.bodyLines.some((line: string) => line.startsWith('Did you mean'))
).toBe(false);
expect(error.bodyLines).toContain(' - build');
});

it('should report when the project has no targets', () => {
const error = getRunOneTargetError(
{
name: 'empty',
type: 'lib',
data: { root: 'libs/empty', targets: {} },
},
'build'
)!;

expect(error.title).toBe(
'Cannot find target "build" for project "empty"'
);
expect(error.bodyLines).toContain(
'The project "empty" does not have any targets configured.'
);
});

it('should suggest a colon-containing target even though its tail is parsed as a configuration', () => {
// `nx run colon-proj:zzcustom:variantt` splits to target "zzcustom",
// configuration "variantt"; the real target name contains the colon.
const error = getRunOneTargetError(
projectGraph.nodes['colon-proj'],
'zzcustom',
'variantt'
)!;

expect(error.title).toBe(
'Cannot find target "zzcustom" for project "colon-proj"'
);
expect(error.bodyLines).toContain('Did you mean "zzcustom:variant"?');
});

it('should still suggest the target for a genuine target + configuration typo', () => {
// Here "production" is a real configuration name, not part of the target,
// so the bare target form must still win.
const error = getRunOneTargetError(
projectGraph.nodes['my-app'],
'biuld',
'production'
)!;

expect(error.bodyLines).toContain('Did you mean "build"?');
});

it('should cap the listed targets to five and report the remainder', () => {
const error = getRunOneTargetError(
{
name: 'many',
type: 'lib',
data: {
root: 'libs/many',
targets: {
build: {},
test: {},
lint: {},
serve: {},
e2e: {},
docs: {},
deploy: {},
},
},
},
'nope'
)!;

const targetLines = error.bodyLines.filter((line: string) =>
line.startsWith(' - ')
);
expect(targetLines).toHaveLength(5);
expect(error.bodyLines).toContain(' ...and 2 more');
});
});

describe('getCannotFindProjectError', () => {
it('should suggest the closest task id when the project name has a typo', () => {
const error = getCannotFindProjectError(projectGraph, 'my-ap', 'serve');

expect(error.title).toBe("Cannot find project 'my-ap'");
expect(error.bodyLines).toContain('Did you mean one of these?');
expect(error.bodyLines).toContain(' - my-app:serve');
});

it('should omit suggestions when nothing is close enough', () => {
const error = getCannotFindProjectError(
projectGraph,
'completely-unrelated',
'whatever'
);

expect(error.title).toBe("Cannot find project 'completely-unrelated'");
expect(error.bodyLines).toEqual([]);
});

it('should suggest the full task id for a project typo on a colon target', () => {
// `nx run colon-pro:zzcustom:variant` -> project "colon-pro" (typo),
// target "zzcustom", configuration "variant".
const error = getCannotFindProjectError(
projectGraph,
'colon-pro',
'zzcustom',
'variant'
);

expect(error.title).toBe("Cannot find project 'colon-pro'");
expect(error.bodyLines).toContain(' - colon-proj:zzcustom:variant');
});
});

describe('complex scenarios', () => {
it('should handle project:target format without configuration', () => {
const parsedArgs = {
Expand Down
160 changes: 155 additions & 5 deletions packages/nx/src/command-line/run/run-one.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { findMatchingProjects } from '../../utils/find-matching-projects';
import { output } from '../../utils/output';
import { splitTarget } from '../../utils/split-target';
import { findClosestMatches } from '../../utils/string-similarity';
import { workspaceRoot } from '../../utils/workspace-root';
import { generateGraph } from '../graph/graph';
import { connectToNxCloudIfExplicitlyAsked } from '../nx-cloud/connect/connect-to-nx-cloud';
Expand Down Expand Up @@ -57,7 +58,22 @@ export async function runOne(
nxJson
);

const { projects, projectName } = getProjects(projectGraph, opts.project);
const { projects, projectName } = getProjects(
projectGraph,
opts.project,
opts.target,
opts.configuration
);

const targetError = getRunOneTargetError(
projects[0],
opts.target,
opts.configuration
);
if (targetError) {
output.error(targetError);
process.exit(1);
}

if (nxArgs.help) {
await (
Expand Down Expand Up @@ -106,7 +122,9 @@ export async function runOne(

function getProjects(
projectGraph: ProjectGraph,
projectName: string
projectName: string,
target: string,
configuration?: string
): {
projectName: string;
projects: ProjectGraphProjectNode[];
Expand Down Expand Up @@ -142,12 +160,144 @@ function getProjects(
}
}

output.error({
title: `Cannot find project '${projectName}'`,
});
output.error(
getCannotFindProjectError(projectGraph, projectName, target, configuration)
);
process.exit(1);
}

// Keep error output scannable: only show a handful of targets/suggestions.
const MAX_LISTED_TARGETS = 5;
const MAX_SUGGESTIONS = 3;

/**
* Builds the list of all runnable `project:target` task ids in the workspace,
* used to suggest the right invocation when a project or target can't be found.
*/
function getProjectTargetIds(projectGraph: ProjectGraph): string[] {
const ids: string[] = [];
for (const projectName of Object.keys(projectGraph.nodes)) {
const targets = projectGraph.nodes[projectName].data.targets ?? {};
for (const targetName of Object.keys(targets)) {
ids.push(`${projectName}:${targetName}`);
}
}
return ids;
}

/**
* A target (and even a configuration) name can legally contain ':'. When the
* user typos such a name, `splitTarget` splits on every ':' and parses the
* trailing segment(s) as separate parts (e.g. `nx:zzcustom:variantt` becomes
* project `nx`, target `zzcustom`, configuration `variantt`).
*
* To match the real name we therefore try the fully-rejoined specifier first,
* then progressively shorter forms. Rejoining wins for colon-containing names,
* while the shorter forms keep matching genuine `target` + `configuration`
* typos (where the real target name does not carry the configuration suffix).
*/
function findClosestSpecifier(
parts: (string | undefined)[],
candidates: readonly string[],
limit = 1
): string[] {
const present = parts.filter((part): part is string => !!part);
for (let end = present.length; end >= 1; end--) {
const matches = findClosestMatches(
present.slice(0, end).join(':'),
candidates,
limit
);
if (matches.length) {
return matches;
}
}
return [];
}

/**
* Lists up to `MAX_LISTED_TARGETS` targets, appending a "...and N more" line
* when the project has more targets than we show.
*/
function formatAvailableTargets(availableTargets: string[]): string[] {
const sorted = [...availableTargets].sort();
const shown = sorted.slice(0, MAX_LISTED_TARGETS);
const lines = ['Available targets:', ...shown.map((t) => ` - ${t}`)];
if (sorted.length > shown.length) {
lines.push(` ...and ${sorted.length - shown.length} more`);
}
return lines;
}

/**
* Builds the error shown when the project itself can't be found. Matches the
* attempted `project:target[:configuration]` against every runnable task id so
* a project typo (e.g. `webpai:build`) still surfaces the intended task
* (`webapi:build`).
*/
export function getCannotFindProjectError(
projectGraph: ProjectGraph,
projectName: string,
target: string,
configuration?: string
): { title: string; bodyLines: string[] } {
const bodyLines: string[] = [];
const suggestions = findClosestSpecifier(
[projectName, target, configuration],
getProjectTargetIds(projectGraph),
MAX_SUGGESTIONS
);
if (suggestions.length) {
bodyLines.push(
'Did you mean one of these?',
...suggestions.map((id) => ` - ${id}`)
);
}

return {
title: `Cannot find project '${projectName}'`,
bodyLines,
};
}

/**
* Validates that `target` exists on the resolved project. When it does not,
* returns an error describing the available targets (and the closest match, if
* any) so the user can recover from a typo or discover what they can run.
*/
export function getRunOneTargetError(
project: ProjectGraphProjectNode,
target: string,
configuration?: string
): { title: string; bodyLines: string[] } | null {
const availableTargets = Object.keys(project.data.targets ?? {});
if (availableTargets.includes(target)) {
return null;
}

const bodyLines: string[] = [];
const [closestMatch] = findClosestSpecifier(
[target, configuration],
availableTargets
);
if (closestMatch) {
bodyLines.push(`Did you mean "${closestMatch}"?`, '');
}

if (availableTargets.length) {
bodyLines.push(...formatAvailableTargets(availableTargets));
} else {
bodyLines.push(
`The project "${project.name}" does not have any targets configured.`
);
}

return {
title: `Cannot find target "${target}" for project "${project.name}"`,
bodyLines,
};
}

const targetAliases = {
b: 'build',
e: 'e2e',
Expand Down
Loading
Loading