chore(workspace): improved logging design and consistency

This commit is contained in:
James Henry 2019-07-22 13:42:42 -04:00 committed by Victor Savkin
parent 52885744d9
commit 8271c7650e
17 changed files with 537 additions and 186 deletions

View File

@ -9,7 +9,21 @@ import {
runCLI runCLI
} from './utils'; } from './utils';
let originalCIValue;
describe('Affected', () => { describe('Affected', () => {
/**
* Setting CI=true makes it simpler to configure assertions around output, as there
* won't be any colors.
*/
beforeAll(() => {
originalCIValue = process.env.CI;
process.env.CI = 'true';
});
afterAll(() => {
process.env.CI = originalCIValue;
});
it('should print, build, and test affected apps', () => { it('should print, build, and test affected apps', () => {
ensureProject(); ensureProject();
const myapp = uniq('myapp'); const myapp = uniq('myapp');
@ -89,9 +103,10 @@ describe('Affected', () => {
const build = runCommand( const build = runCommand(
`npm run affected:build -- --files="libs/${mylib}/src/index.ts"` `npm run affected:build -- --files="libs/${mylib}/src/index.ts"`
); );
expect(build).toContain( expect(build).toContain(`Running target build for projects:`);
`Running build for projects:\n ${myapp},\n ${mypublishablelib}` expect(build).toContain(myapp);
); expect(build).toContain(mypublishablelib);
expect(build).not.toContain('is not registered with the build command'); expect(build).not.toContain('is not registered with the build command');
expect(build).not.toContain('with flags:'); expect(build).not.toContain('with flags:');
@ -99,28 +114,26 @@ describe('Affected', () => {
const buildParallel = runCommand( const buildParallel = runCommand(
`npm run affected:build -- --files="libs/${mylib}/src/index.ts" --parallel` `npm run affected:build -- --files="libs/${mylib}/src/index.ts" --parallel`
); );
expect(buildParallel).toContain(`Running target build for projects:`);
expect(buildParallel).toContain(myapp);
expect(buildParallel).toContain(mypublishablelib);
expect(buildParallel).toContain( expect(buildParallel).toContain(
`Running build for projects:\n ${myapp},\n ${mypublishablelib}` 'Running target "build" for affected projects succeeded'
);
expect(buildParallel).toContain(
'Running build for affected projects succeeded.'
); );
const buildExcluded = runCommand( const buildExcluded = runCommand(
`npm run affected:build -- --files="libs/${mylib}/src/index.ts" --exclude ${myapp}` `npm run affected:build -- --files="libs/${mylib}/src/index.ts" --exclude ${myapp}`
); );
expect(buildExcluded).toContain( expect(buildExcluded).toContain(`Running target build for projects:`);
`Running build for projects:\n ${mypublishablelib}` expect(buildExcluded).toContain(mypublishablelib);
);
// affected:build should pass non-nx flags to the CLI // affected:build should pass non-nx flags to the CLI
const buildWithFlags = runCommand( const buildWithFlags = runCommand(
`npm run affected:build -- --files="libs/${mylib}/src/index.ts" --stats-json` `npm run affected:build -- --files="libs/${mylib}/src/index.ts" --stats-json`
); );
expect(buildWithFlags).toContain(`Running target build for projects:`);
expect(buildWithFlags).toContain( expect(buildWithFlags).toContain(myapp);
`Running build for projects:\n ${myapp},\n ${mypublishablelib}` expect(buildWithFlags).toContain(mypublishablelib);
);
expect(buildWithFlags).toContain('With flags: --stats-json=true'); expect(buildWithFlags).toContain('With flags: --stats-json=true');
if (!runsInWSL()) { if (!runsInWSL()) {
@ -133,9 +146,10 @@ describe('Affected', () => {
const unitTests = runCommand( const unitTests = runCommand(
`npm run affected:test -- --files="libs/${mylib}/src/index.ts"` `npm run affected:test -- --files="libs/${mylib}/src/index.ts"`
); );
expect(unitTests).toContain( expect(unitTests).toContain(`Running target test for projects:`);
`Running test for projects:\n ${mylib},\n ${myapp},\n ${mypublishablelib}` expect(unitTests).toContain(mylib);
); expect(unitTests).toContain(myapp);
expect(unitTests).toContain(mypublishablelib);
// Fail a Unit Test // Fail a Unit Test
updateFile( updateFile(
@ -149,12 +163,15 @@ describe('Affected', () => {
const failedTests = runCommand( const failedTests = runCommand(
`npm run affected:test -- --files="libs/${mylib}/src/index.ts"` `npm run affected:test -- --files="libs/${mylib}/src/index.ts"`
); );
expect(failedTests).toContain(`Running target test for projects:`);
expect(failedTests).toContain(mylib);
expect(failedTests).toContain(myapp);
expect(failedTests).toContain(mypublishablelib);
expect(failedTests).toContain(`Failed projects:`);
expect(failedTests).toContain(myapp);
expect(failedTests).toContain( expect(failedTests).toContain(
`Running test for projects:\n ${mylib},\n ${mypublishablelib},\n ${myapp}` 'You can isolate the above projects by passing: --only-failed'
);
expect(failedTests).toContain(`Failed projects: ${myapp}`);
expect(failedTests).toContain(
'You can isolate the above projects by passing --only-failed'
); );
expect(readJson('dist/.nx-results')).toEqual({ expect(readJson('dist/.nx-results')).toEqual({
command: 'test', command: 'test',
@ -177,14 +194,17 @@ describe('Affected', () => {
const isolatedTests = runCommand( const isolatedTests = runCommand(
`npm run affected:test -- --files="libs/${mylib}/src/index.ts" --only-failed` `npm run affected:test -- --files="libs/${mylib}/src/index.ts" --only-failed`
); );
expect(isolatedTests).toContain(`Running test for projects:\n ${myapp}`); expect(isolatedTests).toContain(`Running target test for projects:`);
expect(isolatedTests).toContain(myapp);
const linting = runCommand( const linting = runCommand(
`npm run affected:lint -- --files="libs/${mylib}/src/index.ts"` `npm run affected:lint -- --files="libs/${mylib}/src/index.ts"`
); );
expect(linting).toContain( expect(linting).toContain(`Running target lint for projects:`);
`Running lint for projects:\n ${mylib},\n ${myapp},\n ${myapp}-e2e,\n ${mypublishablelib}` expect(linting).toContain(mylib);
); expect(linting).toContain(myapp);
expect(linting).toContain(`${myapp}-e2e`);
expect(linting).toContain(mypublishablelib);
const lintWithJsonFormating = runCommand( const lintWithJsonFormating = runCommand(
`npm run affected:lint -- --files="libs/${mylib}/src/index.ts" -- --format json` `npm run affected:lint -- --files="libs/${mylib}/src/index.ts" -- --format json`
@ -194,20 +214,20 @@ describe('Affected', () => {
const unitTestsExcluded = runCommand( const unitTestsExcluded = runCommand(
`npm run affected:test -- --files="libs/${mylib}/src/index.ts" --exclude=${myapp},${mypublishablelib}` `npm run affected:test -- --files="libs/${mylib}/src/index.ts" --exclude=${myapp},${mypublishablelib}`
); );
expect(unitTestsExcluded).toContain( expect(unitTestsExcluded).toContain(`Running target test for projects:`);
`Running test for projects:\n ${mylib}` expect(unitTestsExcluded).toContain(mylib);
);
const i18n = runCommand( const i18n = runCommand(
`npm run affected -- --target extract-i18n --files="libs/${mylib}/src/index.ts"` `npm run affected -- --target extract-i18n --files="libs/${mylib}/src/index.ts"`
); );
expect(i18n).toContain(`Running extract-i18n for projects:\n ${myapp}`); expect(i18n).toContain(`Running target extract-i18n for projects:`);
expect(i18n).toContain(myapp);
const interpolatedTests = runCommand( const interpolatedTests = runCommand(
`npm run affected -- --target test --files="libs/${mylib}/src/index.ts" -- --jest-config {project.root}/jest.config.js` `npm run affected -- --target test --files="libs/${mylib}/src/index.ts" -- --jest-config {project.root}/jest.config.js`
); );
expect(interpolatedTests).toContain( expect(interpolatedTests).toContain(
`Running test for affected projects succeeded.` `Running target "test" for affected projects succeeded`
); );
}, 1000000); }, 1000000);
}); });

View File

@ -75,10 +75,16 @@ describe('Command line', () => {
const stdout = runCommand('./node_modules/.bin/nx workspace-lint'); const stdout = runCommand('./node_modules/.bin/nx workspace-lint');
expect(stdout).toContain( expect(stdout).toContain(
`Cannot find project '${appBefore}' in 'apps/${appBefore}'` `- Cannot find project '${appBefore}' in 'apps/${appBefore}'`
); );
expect(stdout).toContain( expect(stdout).toContain(
`The 'apps/${appAfter}/browserslist' file doesn't belong to any project.` 'The following file(s) do not belong to any projects:'
);
expect(stdout).toContain(`- apps/${appAfter}/browserslist`);
expect(stdout).toContain(`- apps/${appAfter}/src/app/app.component.css`);
expect(stdout).toContain(`- apps/${appAfter}/src/app/app.component.html`);
expect(stdout).toContain(
`- apps/${appAfter}/src/app/app.component.spec.ts`
); );
}); });
}); });

View File

@ -1,9 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import { output } from '@nrwl/workspace';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { dirSync } from 'tmp';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { dirSync } from 'tmp';
import * as yargsParser from 'yargs-parser'; import * as yargsParser from 'yargs-parser';
const parsedArgs = yargsParser(process.argv, { const parsedArgs = yargsParser(process.argv, {
@ -54,14 +55,19 @@ const projectName = parsedArgs._[2];
// check that the workspace name is passed in // check that the workspace name is passed in
if (!projectName) { if (!projectName) {
console.error( output.error({
'Please provide a project name (e.g., create-nx-workspace nrwl-proj)' title: 'A project name is required when creating a new workspace',
); bodyLines: [
output.colors.gray('For example:'),
'',
`${output.colors.gray('>')} create-nx-workspace my-new-workspace`
]
});
process.exit(1); process.exit(1);
} }
// creating the sandbox // creating the sandbox
console.log(`Creating a sandbox with Nx...`); output.logSingleLine(`Creating a sandbox...`);
const tmpDir = dirSync().name; const tmpDir = dirSync().name;
const nxVersion = 'NX_VERSION'; const nxVersion = 'NX_VERSION';
@ -90,7 +96,13 @@ const args = process.argv
.slice(2) .slice(2)
.map(a => `"${a}"`) .map(a => `"${a}"`)
.join(' '); .join(' ');
console.log(`ng new ${args} --collection=${nxTool.packageName}`);
output.logSingleLine(
`${output.colors.gray('Running:')} ng new ${args} --collection=${
nxTool.packageName
}`
);
execSync( execSync(
`"${path.join( `"${path.join(
tmpDir, tmpDir,

View File

@ -27,6 +27,7 @@
}, },
"homepage": "https://nx.dev", "homepage": "https://nx.dev",
"dependencies": { "dependencies": {
"@nrwl/workspace": "*",
"tmp": "0.0.33", "tmp": "0.0.33",
"yargs-parser": "10.0.0", "yargs-parser": "10.0.0",
"yargs": "^11.0.0" "yargs": "^11.0.0"

View File

@ -19,6 +19,7 @@ export {
ExistingPrettierConfig, ExistingPrettierConfig,
resolveUserExistingPrettierConfig resolveUserExistingPrettierConfig
} from './src/utils/common'; } from './src/utils/common';
export { output } from './src/command-line/output';
export { export {
commandsObject, commandsObject,
supportedNxCommands supportedNxCommands

View File

@ -20,6 +20,7 @@ import {
} from './shared'; } from './shared';
import { generateGraph } from './dep-graph'; import { generateGraph } from './dep-graph';
import { WorkspaceResults } from './workspace-results'; import { WorkspaceResults } from './workspace-results';
import { output } from './output';
export interface YargsAffectedOptions export interface YargsAffectedOptions
extends yargs.Arguments, extends yargs.Arguments,
@ -70,7 +71,12 @@ export function affected(parsedArgs: YargsAffectedOptions): void {
!parsedArgs.onlyFailed || !workspaceResults.getResult(project) !parsedArgs.onlyFailed || !workspaceResults.getResult(project)
); );
printArgsWarning(parsedArgs); printArgsWarning(parsedArgs);
console.log(apps.join(' ')); if (apps.length) {
output.log({
title: 'Affected apps:',
bodyLines: apps.map(app => `${output.colors.gray('-')} ${app}`)
});
}
break; break;
case 'libs': case 'libs':
const libs = (parsedArgs.all const libs = (parsedArgs.all
@ -83,7 +89,12 @@ export function affected(parsedArgs: YargsAffectedOptions): void {
!parsedArgs.onlyFailed || !workspaceResults.getResult(project) !parsedArgs.onlyFailed || !workspaceResults.getResult(project)
); );
printArgsWarning(parsedArgs); printArgsWarning(parsedArgs);
console.log(libs.join(' ')); if (libs.length) {
output.log({
title: 'Affected libs:',
bodyLines: libs.map(lib => `${output.colors.gray('-')} ${lib}`)
});
}
break; break;
case 'dep-graph': case 'dep-graph':
const projects = parsedArgs.all const projects = parsedArgs.all
@ -105,16 +116,7 @@ export function affected(parsedArgs: YargsAffectedOptions): void {
parsedArgs.all parsedArgs.all
); );
printArgsWarning(parsedArgs); printArgsWarning(parsedArgs);
runCommand( runCommand(target, targetProjects, parsedArgs, rest, workspaceResults);
target,
targetProjects,
parsedArgs,
rest,
workspaceResults,
`Running ${target} for`,
`Running ${target} for affected projects succeeded.`,
`Running ${target} for affected projects failed.`
);
break; break;
} }
} catch (e) { } catch (e) {
@ -141,33 +143,50 @@ function getProjects(
} }
function printError(e: any, verbose?: boolean) { function printError(e: any, verbose?: boolean) {
const bodyLines = [e.message];
if (verbose && e.stack) { if (verbose && e.stack) {
console.error(e.stack); bodyLines.push('');
} else { bodyLines.push(e.stack);
console.error(e.message);
} }
output.error({
title: 'There was a critical error when running your command',
bodyLines
});
} }
async function runCommand( async function runCommand(
command: string, targetName: string,
projects: string[], projects: string[],
parsedArgs: YargsAffectedOptions, parsedArgs: YargsAffectedOptions,
args: string[], args: string[],
workspaceResults: WorkspaceResults, workspaceResults: WorkspaceResults
iterationMessage: string,
successMessage: string,
errorMessage: string
) { ) {
if (projects.length <= 0) { if (projects.length <= 0) {
console.log(`No projects to run ${command}`); output.logSingleLine(
`No affected projects to run target "${targetName}" on`
);
return; return;
} }
let message = `${iterationMessage} projects:\n ${projects.join(',\n ')}`; const bodyLines = projects.map(
console.log(message); project => `${output.colors.gray('-')} ${project}`
);
if (args.length > 0) { if (args.length > 0) {
console.log(`With flags: ${args.join(' ')}`); bodyLines.push('');
bodyLines.push(
`${output.colors.gray('With flags:')} ${output.bold(args.join(' '))}`
);
} }
output.log({
title: `${output.colors.gray(
'Running target'
)} ${targetName} ${output.colors.gray('for projects:')}`,
bodyLines
});
output.addVerticalSeparator();
const angularJson = readAngularJson(); const angularJson = readAngularJson();
const projectMetadata = new Map<string, any>(); const projectMetadata = new Map<string, any>();
projects.forEach(project => { projects.forEach(project => {
@ -179,22 +198,32 @@ async function runCommand(
fs.readFileSync('./package.json').toString('utf-8') fs.readFileSync('./package.json').toString('utf-8')
); );
if (!packageJson.scripts || !packageJson.scripts.ng) { if (!packageJson.scripts || !packageJson.scripts.ng) {
console.error( output.error({
'\nError: Your `package.json` file should contain the `ng: "ng"` command in the `scripts` section.\n' title:
); 'The "scripts" section of your `package.json` must contain `"ng": "ng"`',
bodyLines: [
output.colors.gray('...'),
' "scripts": {',
output.colors.gray(' ...'),
' "ng": "ng"',
output.colors.gray(' ...'),
' }',
output.colors.gray('...')
]
});
return process.exit(1); return process.exit(1);
} }
try { try {
await runAll( await runAll(
projects.map(app => { projects.map(app => {
return ngCommands.includes(command) return ngCommands.includes(targetName)
? `ng -- ${command} --project=${app} ${transformArgs( ? `ng -- ${targetName} --project=${app} ${transformArgs(
args, args,
app, app,
projectMetadata.get(app) projectMetadata.get(app)
).join(' ')} ` ).join(' ')} `
: `ng -- run ${app}:${command} ${transformArgs( : `ng -- run ${app}:${targetName} ${transformArgs(
args, args,
app, app,
projectMetadata.get(app) projectMetadata.get(app)
@ -226,8 +255,8 @@ async function runCommand(
workspaceResults.saveResults(); workspaceResults.saveResults();
workspaceResults.printResults( workspaceResults.printResults(
parsedArgs.onlyFailed, parsedArgs.onlyFailed,
successMessage, `Running target "${targetName}" for affected projects succeeded`,
errorMessage `Running target "${targetName}" for affected projects failed`
); );
if (workspaceResults.hasFailure) { if (workspaceResults.hasFailure) {

View File

@ -77,7 +77,6 @@ type ParsedUserOptions = {
type OutputOptions = { type OutputOptions = {
data: string; data: string;
shouldOpen: boolean; shouldOpen: boolean;
shouldWriteToFile: boolean;
filename?: string; filename?: string;
}; };
@ -248,23 +247,15 @@ export function createGraphviz(
return g.to_dot(); return g.to_dot();
} }
function handleOutput({ function handleOutput({ data, shouldOpen, filename }: OutputOptions) {
data,
shouldOpen,
shouldWriteToFile,
filename
}: OutputOptions) {
if (shouldOpen) { if (shouldOpen) {
const tmpFilename = `${tmpNameSync()}.html`; const tmpFilename = `${tmpNameSync()}.html`;
writeToFile(tmpFilename, data); writeToFile(tmpFilename, data);
opn(tmpFilename, { opn(tmpFilename, {
wait: false wait: false
}); });
} else if (!shouldWriteToFile) {
return console.log(data);
} else {
writeToFile(filename, data);
} }
writeToFile(filename, data);
} }
function applyHTMLTemplate(svg: string) { function applyHTMLTemplate(svg: string) {
@ -361,7 +352,6 @@ export function generateGraph(
handleOutput({ handleOutput({
data: extractDataFromJson(projects, json, config.type), data: extractDataFromJson(projects, json, config.type),
filename: config.filename, filename: config.filename,
shouldWriteToFile: config.isFilePresent,
shouldOpen: config.shouldOpen shouldOpen: config.shouldOpen
}); });
} }

View File

@ -5,6 +5,7 @@ import { getProjectRoots, parseFiles, printArgsWarning } from './shared';
import { YargsAffectedOptions } from './affected'; import { YargsAffectedOptions } from './affected';
import { getTouchedProjects } from './touched'; import { getTouchedProjects } from './touched';
import { fileExists } from '../utils/fileutils'; import { fileExists } from '../utils/fileutils';
import { output } from './output';
export interface YargsFormatOptions extends YargsAffectedOptions { export interface YargsFormatOptions extends YargsAffectedOptions {
libsAndApps?: boolean; libsAndApps?: boolean;
@ -27,7 +28,18 @@ export function format(command: 'check' | 'write', args: YargsFormatOptions) {
try { try {
patterns = getPatterns(args); patterns = getPatterns(args);
} catch (e) { } catch (e) {
printError(command, e); output.error({
title: e.message,
bodyLines: [
`Pass the SHA range: ${output.bold(
`npm run format:${command} -- SHA1 SHA2`
)}`,
'',
`Or pass the list of files: ${output.bold(
`npm run format:${command} -- --files="libs/mylib/index.ts,libs/mylib2/index.ts"`
)}`
]
});
process.exit(1); process.exit(1);
} }
@ -86,16 +98,6 @@ function getPatternsWithPathPrefix(prefixes: string[]): string[] {
); );
} }
function printError(command: string, e: any) {
console.error(
`Pass the SHA range, as follows: npm run format:${command} -- SHA1 SHA2.`
);
console.error(
`Or pass the list of files, as follows: npm run format:${command} -- --files="libs/mylib/index.ts,libs/mylib2/index.ts".`
);
console.error(e.message);
}
function write(patterns: string[]) { function write(patterns: string[]) {
if (patterns.length > 0) { if (patterns.length > 0) {
execSync(`node "${prettierPath()}" --write ${patterns.join(' ')}`, { execSync(`node "${prettierPath()}" --write ${patterns.join(' ')}`, {

View File

@ -7,19 +7,19 @@ import {
import { WorkspaceIntegrityChecks } from './workspace-integrity-checks'; import { WorkspaceIntegrityChecks } from './workspace-integrity-checks';
import * as path from 'path'; import * as path from 'path';
import { appRootPath } from '../utils/app-root'; import { appRootPath } from '../utils/app-root';
import { output } from './output';
export function lint() { export function workspaceLint() {
const nodes = getProjectNodes(readAngularJson(), readNxJson()); const nodes = getProjectNodes(readAngularJson(), readNxJson());
const errorGroups = new WorkspaceIntegrityChecks( const cliErrorOutputConfigs = new WorkspaceIntegrityChecks(
nodes, nodes,
readAllFilesFromAppsAndLibs() readAllFilesFromAppsAndLibs()
).run(); ).run();
if (errorGroups.length > 0) {
errorGroups.forEach(g => { if (cliErrorOutputConfigs.length > 0) {
console.error(`${g.header}:`); cliErrorOutputConfigs.forEach(errorConfig => {
g.errors.forEach(e => console.error(e)); output.error(errorConfig);
console.log('');
}); });
process.exit(1); process.exit(1);
} }

View File

@ -3,7 +3,7 @@ import * as yargs from 'yargs';
import { affected } from './affected'; import { affected } from './affected';
import { format } from './format'; import { format } from './format';
import { lint } from './lint'; import { workspaceLint } from './lint';
import { workspaceSchematic } from './workspace-schematic'; import { workspaceSchematic } from './workspace-schematic';
import { generateGraph, OutputType } from './dep-graph'; import { generateGraph, OutputType } from './dep-graph';
@ -136,7 +136,7 @@ export const commandsObject = yargs
'workspace-lint [files..]', 'workspace-lint [files..]',
'Lint workspace or list of files', 'Lint workspace or list of files',
noop, noop,
_ => lint() _ => workspaceLint()
) )
.command( .command(
'workspace-schematic [name]', 'workspace-schematic [name]',

View File

@ -0,0 +1,190 @@
import chalk from 'chalk';
export interface CLIErrorMessageConfig {
title: string;
bodyLines?: string[];
slug?: string;
}
export interface CLIWarnMessageConfig {
title: string;
bodyLines?: string[];
slug?: string;
}
export interface CLILogMessageConfig {
title: string;
bodyLines?: string[];
}
export interface CLINoteMessageConfig {
title: string;
}
export interface CLISuccessMessageConfig {
title: string;
}
/**
* Automatically disable styling applied by chalk if CI=true
*/
if (process.env.CI === 'true') {
chalk.level = 0;
}
class CLIOutput {
private readonly NX_PREFIX = `${chalk.cyan(
'>'
)} ${chalk.reset.inverse.bold.cyan(' NX ')}`;
/**
* Longer dash character which forms more of a continuous line when place side to side
* with itself, unlike the standard dash character
*/
private readonly VERTICAL_SEPARATOR =
'———————————————————————————————————————————————';
/**
* Expose some color and other utility functions so that other parts of the codebase that need
* more fine-grained control of message bodies are still using a centralized
* implementation.
*/
colors = {
gray: chalk.gray
};
bold = chalk.bold;
underline = chalk.underline;
private writeToStdOut(str: string) {
process.stdout.write(str);
}
private writeOutputTitle({
label,
title
}: {
label?: string;
title: string;
}): void {
let outputTitle: string;
if (label) {
outputTitle = `${this.NX_PREFIX} ${label} ${title}\n`;
} else {
outputTitle = `${this.NX_PREFIX} ${title}\n`;
}
this.writeToStdOut(outputTitle);
}
private writeOptionalOutputBody(bodyLines?: string[]): void {
if (!bodyLines) {
return;
}
this.addNewline();
bodyLines.forEach(bodyLine => this.writeToStdOut(' ' + bodyLine + '\n'));
}
addNewline() {
this.writeToStdOut('\n');
}
addVerticalSeparator() {
this.writeToStdOut(`\n${chalk.gray(this.VERTICAL_SEPARATOR)}\n\n`);
}
error({ title, slug, bodyLines }: CLIErrorMessageConfig) {
this.addNewline();
this.writeOutputTitle({
label: chalk.reset.inverse.bold.red(' ERROR '),
title: chalk.bold.red(title)
});
this.writeOptionalOutputBody(bodyLines);
/**
* Optional slug to be used in an Nx error message redirect URL
*/
if (slug && typeof slug === 'string') {
this.addNewline();
this.writeToStdOut(
chalk.grey(' ' + 'Learn more about this error: ') +
'https://errors.nx.dev/' +
slug +
'\n'
);
}
this.addNewline();
}
warn({ title, slug, bodyLines }: CLIWarnMessageConfig) {
this.addNewline();
this.writeOutputTitle({
label: chalk.reset.inverse.bold.yellow(' WARNING '),
title: chalk.bold.yellow(title)
});
this.writeOptionalOutputBody(bodyLines);
/**
* Optional slug to be used in an Nx warning message redirect URL
*/
if (slug && typeof slug === 'string') {
this.addNewline();
this.writeToStdOut(
chalk.grey(' ' + 'Learn more about this warning: ') +
'https://errors.nx.dev/' +
slug +
'\n'
);
}
this.addNewline();
}
note({ title }: CLINoteMessageConfig) {
this.addNewline();
this.writeOutputTitle({
label: chalk.reset.inverse.bold.keyword('orange')(' NOTE '),
title: chalk.bold.keyword('orange')(title)
});
this.addNewline();
}
success({ title }: CLISuccessMessageConfig) {
this.addNewline();
this.writeOutputTitle({
label: chalk.reset.inverse.bold.green(' SUCCESS '),
title: chalk.bold.green(title)
});
this.addNewline();
}
logSingleLine(message: string) {
this.addNewline();
this.writeOutputTitle({
title: message
});
this.addNewline();
}
log({ title, bodyLines }: CLIWarnMessageConfig) {
this.addNewline();
this.writeOutputTitle({
title: chalk.white(title)
});
this.writeOptionalOutputBody(bodyLines);
this.addNewline();
}
}
export const output = new CLIOutput();

View File

@ -15,6 +15,7 @@ import { YargsAffectedOptions } from './affected';
import { readDependencies, DepGraph, Deps } from './deps-calculator'; import { readDependencies, DepGraph, Deps } from './deps-calculator';
import { touchedProjects } from './touched'; import { touchedProjects } from './touched';
import { appRootPath } from '../utils/app-root'; import { appRootPath } from '../utils/app-root';
import { output } from './output';
const ignore = require('ignore'); const ignore = require('ignore');
@ -57,27 +58,25 @@ export function printArgsWarning(options: YargsAffectedOptions) {
!all && !all &&
options._.length < 2 options._.length < 2
) { ) {
console.log('Note: Nx defaulted to --base=master --head=HEAD'); output.note({
title: `Affected criteria defaulted to --base=${output.bold(
'master'
)} --head=${output.bold('HEAD')}`
});
} }
if (all) { if (all) {
console.warn( output.warn({
'****************************************************************************************' title: `Running affected:* commands with --all can result in very slow builds.`,
); bodyLines: [
console.warn('WARNING:'); output.bold('--all') +
console.warn( ' is not meant to be used for any sizable project or to be used in CI.',
'Running affected:* commands with --all can result in very slow builds.' '',
); output.colors.gray(
console.warn( 'Learn more about checking only what is affected: '
'It is not meant to be used for any sizable project or to be used in CI.' ) + 'https://nx.dev/guides/monorepo-affected.'
); ]
console.warn( });
'Read about rebuilding and retesting only what is affected here:'
);
console.warn('https://nx.dev/guides/monorepo-affected.');
console.warn(
'****************************************************************************************'
);
} }
} }

View File

@ -1,8 +1,9 @@
import { WorkspaceIntegrityChecks } from './workspace-integrity-checks'; import { WorkspaceIntegrityChecks } from './workspace-integrity-checks';
import { ProjectType } from './affected-apps'; import { ProjectType } from './affected-apps';
import chalk from 'chalk';
describe('WorkspaceIntegrityChecks', () => { describe('WorkspaceIntegrityChecks', () => {
describe('.angular-cli.json is in sync with the filesystem', () => { describe('angular.json is in sync with the filesystem', () => {
it('should not error when they are in sync', () => { it('should not error when they are in sync', () => {
const c = new WorkspaceIntegrityChecks( const c = new WorkspaceIntegrityChecks(
[ [
@ -54,10 +55,16 @@ describe('WorkspaceIntegrityChecks', () => {
); );
const errors = c.run(); const errors = c.run();
expect(errors.length).toEqual(1); expect(errors).toEqual([
expect(errors[0].errors[0]).toEqual( {
`Cannot find project 'project1' in 'libs/project1'` bodyLines: [
); `${chalk.grey(
'-'
)} Cannot find project 'project1' in 'libs/project1'`
],
title: 'The angular.json file is out of sync'
}
]);
}); });
it('should error when there are files in apps or libs without projects', () => { it('should error when there are files in apps or libs without projects', () => {
@ -80,10 +87,12 @@ describe('WorkspaceIntegrityChecks', () => {
); );
const errors = c.run(); const errors = c.run();
expect(errors.length).toEqual(1); expect(errors).toEqual([
expect(errors[0].errors[0]).toEqual( {
`The 'libs/project2/src/index.ts' file doesn't belong to any project.` bodyLines: [`${chalk.grey('-')} libs/project2/src/index.ts`],
); title: 'The following file(s) do not belong to any projects:'
}
]);
}); });
}); });
}); });

View File

@ -1,28 +1,37 @@
import { ProjectNode } from './affected-apps'; import { ProjectNode } from './affected-apps';
import { output, CLIErrorMessageConfig } from './output';
export interface ErrorGroup {
header: string;
errors: string[];
}
export class WorkspaceIntegrityChecks { export class WorkspaceIntegrityChecks {
constructor(private projectNodes: ProjectNode[], private files: string[]) {} constructor(private projectNodes: ProjectNode[], private files: string[]) {}
run(): ErrorGroup[] { run(): CLIErrorMessageConfig[] {
return [...this.projectWithoutFilesCheck(), ...this.filesWithoutProjects()]; return [...this.projectWithoutFilesCheck(), ...this.filesWithoutProjects()];
} }
private projectWithoutFilesCheck(): ErrorGroup[] { private projectWithoutFilesCheck(): CLIErrorMessageConfig[] {
const errors = this.projectNodes const errors = this.projectNodes
.filter(n => n.files.length === 0) .filter(n => n.files.length === 0)
.map(p => `Cannot find project '${p.name}' in '${p.root}'`); .map(p => `Cannot find project '${p.name}' in '${p.root}'`);
const errorGroupBodyLines = errors.map(
f => `${output.colors.gray('-')} ${f}`
);
return errors.length === 0 return errors.length === 0
? [] ? []
: [{ header: 'The angular.json file is out of sync', errors }]; : [
{
title: 'The angular.json file is out of sync',
bodyLines: errorGroupBodyLines
/**
* TODO(JamesHenry): Add support for error documentation
*/
// slug: 'project-has-no-files'
}
];
} }
private filesWithoutProjects(): ErrorGroup[] { private filesWithoutProjects(): CLIErrorMessageConfig[] {
const allFilesFromProjects = this.allProjectFiles(); const allFilesFromProjects = this.allProjectFiles();
const allFilesWithoutProjects = minus(this.files, allFilesFromProjects); const allFilesWithoutProjects = minus(this.files, allFilesFromProjects);
const first5FilesWithoutProjects = const first5FilesWithoutProjects =
@ -30,16 +39,20 @@ export class WorkspaceIntegrityChecks {
? allFilesWithoutProjects.slice(0, 5) ? allFilesWithoutProjects.slice(0, 5)
: allFilesWithoutProjects; : allFilesWithoutProjects;
const errors = first5FilesWithoutProjects.map( const errorGroupBodyLines = first5FilesWithoutProjects.map(
p => `The '${p}' file doesn't belong to any project.` f => `${output.colors.gray('-')} ${f}`
); );
return errors.length === 0 return first5FilesWithoutProjects.length === 0
? [] ? []
: [ : [
{ {
header: `All files in 'apps' and 'libs' must be part of a project`, title: `The following file(s) do not belong to any projects:`,
errors bodyLines: errorGroupBodyLines
/**
* TODO(JamesHenry): Add support for error documentation
*/
// slug: 'file-does-not-belong-to-project'
} }
]; ];
} }

View File

@ -3,6 +3,7 @@ import * as fs from 'fs';
import { WorkspaceResults } from './workspace-results'; import { WorkspaceResults } from './workspace-results';
import { serializeJson } from '../utils/fileutils'; import { serializeJson } from '../utils/fileutils';
import { stripIndents } from '@angular-devkit/core/src/utils/literals'; import { stripIndents } from '@angular-devkit/core/src/utils/literals';
import { output } from './output';
describe('WorkspacesResults', () => { describe('WorkspacesResults', () => {
let results: WorkspaceResults; let results: WorkspaceResults;
@ -39,25 +40,46 @@ describe('WorkspacesResults', () => {
}); });
it('should print results', () => { it('should print results', () => {
results.success('proj'); const projectName = 'proj';
spyOn(console, 'log'); results.success(projectName);
spyOn(output, 'success');
results.printResults(false, 'Success', 'Fail'); const successTitle = 'Success';
expect(console.log).toHaveBeenCalledWith('Success'); results.printResults(false, successTitle, 'Fail');
expect(output.success).toHaveBeenCalledWith({
title: successTitle
});
}); });
it('should tell warn the user that not all tests were run', () => { it('should warn the user that not all tests were run', () => {
(<any>results).startedWithFailedProjects = true; (<any>results).startedWithFailedProjects = true;
results.success('proj');
spyOn(console, 'warn');
results.printResults(true, 'Success', 'Fail'); const projectName = 'proj';
spyOn(output, 'success');
spyOn(output, 'warn');
expect(console.warn).toHaveBeenCalledWith(stripIndents` results.success(projectName);
Warning: Only failed affected projects were run.
You should run above command WITHOUT --only-failed const successTitle = 'Success';
`);
results.printResults(true, successTitle, 'Fail');
expect(output.success).toHaveBeenCalledWith({
title: successTitle
});
expect(output.warn).toHaveBeenCalledWith({
title: `Only affected projects ${output.underline(
'which had previously failed'
)} were run`,
bodyLines: [
`You should verify by running ${output.underline(
'without'
)} ${output.bold('--only-failed')}`
]
});
}); });
}); });
@ -86,23 +108,45 @@ describe('WorkspacesResults', () => {
}); });
it('should print results', () => { it('should print results', () => {
results.fail('proj'); const projectName = 'proj';
spyOn(console, 'error'); results.fail(projectName);
spyOn(output, 'error');
results.printResults(true, 'Success', 'Fail'); const errorTitle = 'Fail';
expect(console.error).toHaveBeenCalledWith('Fail'); results.printResults(true, 'Success', errorTitle);
expect(output.error).toHaveBeenCalledWith({
title: errorTitle,
bodyLines: [
output.colors.gray('Failed projects:'),
'',
`${output.colors.gray('-')} ${projectName}`
]
});
}); });
it('should tell warn the user that not all tests were run', () => { it('should tell the user that they can isolate only the failed tests', () => {
results.fail('proj'); const projectName = 'proj';
spyOn(console, 'log'); results.fail(projectName);
spyOn(output, 'error');
results.printResults(false, 'Success', 'Fail'); const errorTitle = 'Fail';
expect(console.log).toHaveBeenCalledWith( results.printResults(false, 'Success', errorTitle);
`You can isolate the above projects by passing --only-failed`
); expect(output.error).toHaveBeenCalledWith({
title: errorTitle,
bodyLines: [
output.colors.gray('Failed projects:'),
'',
`${output.colors.gray('-')} ${projectName}`,
'',
`${output.colors.gray(
'You can isolate the above projects by passing:'
)} ${output.bold('--only-failed')}`
]
});
}); });
}); });

View File

@ -1,7 +1,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import { readJsonFile, writeJsonFile } from '../utils/fileutils'; import { readJsonFile, writeJsonFile } from '../utils/fileutils';
import { unlinkSync } from 'fs'; import { unlinkSync } from 'fs';
import { stripIndents } from '@angular-devkit/core/src/utils/literals'; import { output } from './output';
const RESULTS_FILE = 'dist/.nx-results'; const RESULTS_FILE = 'dist/.nx-results';
@ -37,10 +37,12 @@ export class WorkspaceResults {
if (this.startedWithFailedProjects) { if (this.startedWithFailedProjects) {
this.commandResults = commandResults; this.commandResults = commandResults;
} }
} catch (e) { } catch {
// RESULTS_FILE is likely not valid JSON /**
console.error('Error: .nx-results file is corrupted.'); * If we got here it is likely that RESULTS_FILE is not valid JSON.
console.error(e); * It is safe to continue, and it does not make much sense to give the
* user feedback as the file will be updated automatically.
*/
} }
} }
} }
@ -70,24 +72,52 @@ export class WorkspaceResults {
successMessage: string, successMessage: string,
failureMessage: string failureMessage: string
) { ) {
const failedProjects = this.failedProjects; /**
* Leave a bit of breathing room between the process output
* and our formatted results.
*/
output.addNewline();
output.addVerticalSeparator();
if (this.failedProjects.length === 0) { if (this.failedProjects.length === 0) {
console.log(successMessage); output.success({
title: successMessage
});
if (onlyFailed && this.startedWithFailedProjects) { if (onlyFailed && this.startedWithFailedProjects) {
console.warn(stripIndents` output.warn({
Warning: Only failed affected projects were run. title: `Only affected projects ${output.underline(
You should run above command WITHOUT --only-failed 'which had previously failed'
`); )} were run`,
} bodyLines: [
} else { `You should verify by running ${output.underline(
console.error(failureMessage); 'without'
console.log(`Failed projects: ${failedProjects.join(',')}`); )} ${output.bold('--only-failed')}`
if (!onlyFailed && !this.startedWithFailedProjects) { ]
console.log( });
`You can isolate the above projects by passing --only-failed`
);
} }
return;
} }
const bodyLines = [
output.colors.gray('Failed projects:'),
'',
...this.failedProjects.map(
project => `${output.colors.gray('-')} ${project}`
)
];
if (!onlyFailed && !this.startedWithFailedProjects) {
bodyLines.push('');
bodyLines.push(
`${output.colors.gray(
'You can isolate the above projects by passing:'
)} ${output.bold('--only-failed')}`
);
}
output.error({
title: failureMessage,
bodyLines
});
} }
private setResult(projectName: string, result: boolean) { private setResult(projectName: string, result: boolean) {

View File

@ -23,6 +23,7 @@ import * as path from 'path';
import * as yargsParser from 'yargs-parser'; import * as yargsParser from 'yargs-parser';
import { fileExists } from '../utils/fileutils'; import { fileExists } from '../utils/fileutils';
import { appRootPath } from '../utils/app-root'; import { appRootPath } from '../utils/app-root';
import { output } from './output';
const rootDirectory = appRootPath; const rootDirectory = appRootPath;
@ -204,6 +205,10 @@ async function executeSchematic(
outDir: string, outDir: string,
logger: logging.Logger logger: logging.Logger
) { ) {
output.logSingleLine(
`${output.colors.gray(`Executing your local schematic`)}: ${schematicName}`
);
let nothingDone = true; let nothingDone = true;
workflow.reporter.subscribe((event: any) => { workflow.reporter.subscribe((event: any) => {
nothingDone = false; nothingDone = false;