diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 203e9351fb36..5de4e4e43741 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -19,6 +19,7 @@ import { } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; import { createAnalytics } from '../analytics/analytics'; +import { considerSettingUpAutocompletion } from '../utilities/completion'; import { AngularWorkspace } from '../utilities/config'; import { memoize } from '../utilities/memoize'; import { PackageManagerUtils } from '../utilities/package-manager'; @@ -123,6 +124,17 @@ export abstract class CommandModule implements CommandModuleI camelCasedOptions[yargsParser.camelCase(key)] = value; } + // Set up autocompletion if appropriate. + const autocompletionExitCode = await considerSettingUpAutocompletion( + this.commandName, + this.context.logger, + ); + if (autocompletionExitCode !== undefined) { + process.exitCode = autocompletionExitCode; + + return; + } + // Gather and report analytics. const analytics = await this.getAnalytics(); if (this.shouldReportAnalytics) { diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts index 13a74ef6da1b..6879726592a9 100644 --- a/packages/angular/cli/src/commands/completion/cli.ts +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -8,13 +8,51 @@ import { join } from 'path'; import yargs, { Argv } from 'yargs'; -import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../command-builder/command-module'; +import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; +import { colors } from '../../utilities/color'; +import { initializeAutocomplete } from '../../utilities/completion'; export class CompletionCommandModule extends CommandModule implements CommandModuleImplementation { command = 'completion'; - describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; + describe = 'Set up Angular CLI autocompletion for your terminal.'; longDescriptionPath = join(__dirname, 'long-description.md'); + builder(localYargs: Argv): Argv { + return addCommandModuleToYargs(localYargs, CompletionScriptCommandModule, this.context); + } + + async run(): Promise { + let rcFile: string; + try { + rcFile = await initializeAutocomplete(); + } catch (err) { + this.context.logger.error(err.message); + + return 1; + } + + this.context.logger.info( + ` +Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands: + + ${colors.yellow('source <(ng completion script)')} + `.trim(), + ); + + return 0; + } +} + +class CompletionScriptCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'script'; + describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; + longDescriptionPath = undefined; + builder(localYargs: Argv): Argv { return localYargs; } diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md index 59f8e107b58a..fabaa7fafe85 100644 --- a/packages/angular/cli/src/commands/completion/long-description.md +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -1 +1,5 @@ -To enable bash and zsh real-time type-ahead autocompletion, copy and paste the generated script to your `.bashrc`, `.bash_profile`, `.zshrc` or `.zsh_profile`. +To enable Bash and Zsh real-time type-ahead autocompletion, run +`ng completion` and restart your terminal. + +Alternatively, append `source <(ng completion script)` to the appropriate `.bashrc`, +`.bash_profile`, `.zshrc`, `.zsh_profile`, or `.profile` file. diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts index 9d19a7065475..c0e09de137e0 100644 --- a/packages/angular/cli/src/commands/config/cli.ts +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -106,6 +106,8 @@ export class ConfigCommandModule 'cli.analytics', 'cli.analyticsSharing.tracking', 'cli.analyticsSharing.uuid', + + 'cli.completion.prompted', ]); if ( diff --git a/packages/angular/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts new file mode 100644 index 000000000000..42c006db08ad --- /dev/null +++ b/packages/angular/cli/src/utilities/completion.ts @@ -0,0 +1,246 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { json, logging } from '@angular-devkit/core'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { env } from 'process'; +import { colors } from '../utilities/color'; +import { getWorkspace } from '../utilities/config'; +import { forceAutocomplete } from '../utilities/environment-options'; +import { isTTY } from '../utilities/tty'; + +/** Interface for the autocompletion configuration stored in the global workspace. */ +interface CompletionConfig { + /** + * Whether or not the user has been prompted to set up autocompletion. If `true`, should *not* + * prompt them again. + */ + prompted?: boolean; +} + +/** + * Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If + * so prompts and sets up autocompletion for the user. Returns an exit code if the program should + * terminate, otherwise returns `undefined`. + * @returns an exit code if the program should terminate, undefined otherwise. + */ +export async function considerSettingUpAutocompletion( + command: string, + logger: logging.Logger, +): Promise { + // Check if we should prompt the user to setup autocompletion. + const completionConfig = await getCompletionConfig(); + if (!(await shouldPromptForAutocompletionSetup(command, completionConfig))) { + return undefined; // Already set up or prompted previously, nothing to do. + } + + // Prompt the user and record their response. + const shouldSetupAutocompletion = await promptForAutocompletion(); + if (!shouldSetupAutocompletion) { + // User rejected the prompt and doesn't want autocompletion. + logger.info( + ` +Ok, you won't be prompted again. Should you change your mind, the following command will set up autocompletion for you: + + ${colors.yellow(`ng completion`)} + `.trim(), + ); + + // Save configuration to remember that the user was prompted and avoid prompting again. + await setCompletionConfig({ ...completionConfig, prompted: true }); + + return undefined; + } + + // User accepted the prompt, set up autocompletion. + let rcFile: string; + try { + rcFile = await initializeAutocomplete(); + } catch (err) { + // Failed to set up autocompeletion, log the error and abort. + logger.error(err.message); + + return 1; + } + + // Notify the user autocompletion was set up successfully. + logger.info( + ` +Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands: + + ${colors.yellow(`source <(ng completion script)`)} + `.trim(), + ); + + // Save configuration to remember that the user was prompted. + await setCompletionConfig({ ...completionConfig, prompted: true }); + + return undefined; +} + +async function getCompletionConfig(): Promise { + const wksp = await getWorkspace('global'); + + return wksp?.getCli()?.['completion']; +} + +async function setCompletionConfig(config: CompletionConfig): Promise { + const wksp = await getWorkspace('global'); + if (!wksp) { + throw new Error(`Could not find global workspace`); + } + + wksp.extensions['cli'] ??= {}; + const cli = wksp.extensions['cli']; + if (!json.isJsonObject(cli)) { + throw new Error( + `Invalid config found at ${wksp.filePath}. \`extensions.cli\` should be an object.`, + ); + } + cli.completion = config as json.JsonObject; + await wksp.save(); +} + +async function shouldPromptForAutocompletionSetup( + command: string, + config?: CompletionConfig, +): Promise { + // Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip. + if (forceAutocomplete !== undefined) { + return forceAutocomplete; + } + + // Don't prompt on `ng update` or `ng completion`. + if (command === 'update' || command === 'completion') { + return false; + } + + // Non-interactive and continuous integration systems don't care about autocompletion. + if (!isTTY()) { + return false; + } + + // Skip prompt if the user has already been prompted. + if (config?.prompted) { + return false; + } + + // `$HOME` variable is necessary to find RC files to modify. + const home = env['HOME']; + if (!home) { + return false; + } + + // Get possible RC files for the current shell. + const shell = env['SHELL']; + if (!shell) { + return false; + } + const rcFiles = getShellRunCommandCandidates(shell, home); + if (!rcFiles) { + return false; // Unknown shell. + } + + // Check each RC file if they already use `ng completion script` in any capacity and don't prompt. + for (const rcFile of rcFiles) { + const contents = await fs.readFile(rcFile, 'utf-8').catch(() => undefined); + if (contents?.includes('ng completion script')) { + return false; + } + } + + return true; +} + +async function promptForAutocompletion(): Promise { + // Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for + // the 99% of builds that *don't* prompt for autocompletion. + const { prompt } = await import('inquirer'); + const { autocomplete } = await prompt<{ autocomplete: boolean }>([ + { + name: 'autocomplete', + type: 'confirm', + message: ` +Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing +Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion +will modify configuration files in your home directory.) + ` + .split('\n') + .join(' ') + .trim(), + default: true, + }, + ]); + + return autocomplete; +} + +/** + * Sets up autocompletion for the user's terminal. This attempts to find the configuration file for + * the current shell (`.bashrc`, `.zshrc`, etc.) and append a command which enables autocompletion + * for the Angular CLI. Supports only Bash and Zsh. Returns whether or not it was successful. + * @return The full path of the configuration file modified. + */ +export async function initializeAutocomplete(): Promise { + // Get the currently active `$SHELL` and `$HOME` environment variables. + const shell = env['SHELL']; + if (!shell) { + throw new Error( + '`$SHELL` environment variable not set. Angular CLI autocompletion only supports Bash or' + + ' Zsh.', + ); + } + const home = env['HOME']; + if (!home) { + throw new Error( + '`$HOME` environment variable not set. Setting up autocompletion modifies configuration files' + + ' in the home directory and must be set.', + ); + } + + // Get all the files we can add `ng completion` to which apply to the user's `$SHELL`. + const runCommandCandidates = getShellRunCommandCandidates(shell, home); + if (!runCommandCandidates) { + throw new Error( + `Unknown \`$SHELL\` environment variable value (${shell}). Angular CLI autocompletion only supports Bash or Zsh.`, + ); + } + + // Get the first file that already exists or fallback to a new file of the first candidate. + const candidates = await Promise.allSettled( + runCommandCandidates.map((rcFile) => fs.access(rcFile).then(() => rcFile)), + ); + const rcFile = + candidates.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled', + )?.value ?? runCommandCandidates[0]; + + // Append Angular autocompletion setup to RC file. + try { + await fs.appendFile( + rcFile, + '\n\n# Load Angular CLI autocompletion.\nsource <(ng completion script)\n', + ); + } catch (err) { + throw new Error(`Failed to append autocompletion setup to \`${rcFile}\`:\n${err.message}`); + } + + return rcFile; +} + +/** Returns an ordered list of possibile candidates of RC files used by the given shell. */ +function getShellRunCommandCandidates(shell: string, home: string): string[] | undefined { + if (shell.toLowerCase().includes('bash')) { + return ['.bashrc', '.bash_profile', '.profile'].map((file) => path.join(home, file)); + } else if (shell.toLowerCase().includes('zsh')) { + return ['.zshrc', '.zsh_profile', '.profile'].map((file) => path.join(home, file)); + } else { + return undefined; + } +} diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts index 1552fe3dbb4a..7febd351b06e 100644 --- a/packages/angular/cli/src/utilities/environment-options.ts +++ b/packages/angular/cli/src/utilities/environment-options.ts @@ -18,8 +18,17 @@ function isEnabled(variable: string | undefined): boolean { return isPresent(variable) && (variable === '1' || variable.toLowerCase() === 'true'); } +function optional(variable: string | undefined): boolean | undefined { + if (!isPresent(variable)) { + return undefined; + } + + return isEnabled(variable); +} + export const analyticsDisabled = isDisabled(process.env['NG_CLI_ANALYTICS']); export const analyticsShareDisabled = isDisabled(process.env['NG_CLI_ANALYTICS_SHARE']); export const isCI = isEnabled(process.env['CI']); export const disableVersionCheck = isEnabled(process.env['NG_DISABLE_VERSION_CHECK']); export const ngDebug = isEnabled(process.env['NG_DEBUG']); +export const forceAutocomplete = optional(process.env['NG_FORCE_AUTOCOMPLETE']); diff --git a/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts b/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts index b85e6c044dbb..1cde8a8f0b71 100644 --- a/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts +++ b/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts @@ -1,49 +1,66 @@ -import { execWithEnv, killAllProcesses, waitForAnyProcessOutputToMatch } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; +import { promises as fs } from 'fs'; +import { execWithEnv } from '../../utils/process'; + +const ANALYTICS_PROMPT = /Would you like to share anonymous usage data/; export default async function () { - try { - // Execute a command with TTY force enabled - execWithEnv('ng', ['version'], { - ...process.env, - NG_FORCE_TTY: '1', - }); + // CLI should prompt for analytics permissions. + await mockHome(async (home) => { + const { stdout } = await execWithEnv( + 'ng', + ['version'], + { + ...process.env, + HOME: home, + NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', + }, + 'y' /* stdin */, + ); - // Check if the prompt is shown - await waitForAnyProcessOutputToMatch(/Would you like to share anonymous usage data/); - } finally { - killAllProcesses(); - } + if (!ANALYTICS_PROMPT.test(stdout)) { + throw new Error('CLI did not prompt for analytics permission.'); + } + }); - try { - // Execute a command with TTY force enabled - execWithEnv('ng', ['version'], { + // CLI should skip analytics prompt with `NG_CLI_ANALYTICS=false`. + await mockHome(async (home) => { + const { stdout } = await execWithEnv('ng', ['version'], { ...process.env, + HOME: home, NG_FORCE_TTY: '1', NG_CLI_ANALYTICS: 'false', + NG_FORCE_AUTOCOMPLETE: 'false', }); - // Check if the prompt is shown - await expectToFail(() => - waitForAnyProcessOutputToMatch(/Would you like to share anonymous usage data/, 5), - ); - } finally { - killAllProcesses(); - } + if (ANALYTICS_PROMPT.test(stdout)) { + throw new Error('CLI prompted for analytics permission when it should be forced off.'); + } + }); - // Should not show a prompt when using update - try { - // Execute a command with TTY force enabled - execWithEnv('ng', ['update'], { + // CLI should skip analytics prompt during `ng update`. + await mockHome(async (home) => { + const { stdout } = await execWithEnv('ng', ['update', '--help'], { ...process.env, + HOME: home, NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', }); - // Check if the prompt is shown - await expectToFail(() => - waitForAnyProcessOutputToMatch(/Would you like to share anonymous usage data/, 5), - ); + if (ANALYTICS_PROMPT.test(stdout)) { + throw new Error( + 'CLI prompted for analytics permission during an update where it should not' + ' have.', + ); + } + }); +} + +async function mockHome(cb: (home: string) => Promise): Promise { + const tempHome = await fs.mkdtemp('angular-cli-e2e-home-'); + + try { + await cb(tempHome); } finally { - killAllProcesses(); + await fs.rm(tempHome, { recursive: true, force: true }); } } diff --git a/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts new file mode 100644 index 000000000000..d6ce1c00a56a --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts @@ -0,0 +1,374 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { env } from 'process'; +import { execAndCaptureError, execWithEnv } from '../../utils/process'; + +const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/; +const DEFAULT_ENV = Object.freeze({ + ...env, + // Shell should be mocked for each test that cares about it. + SHELL: '/bin/bash', + // Even if the actual test process is run on CI, we're testing user flows which aren't on CI. + CI: undefined, + // Tests run on CI technically don't have a TTY, but the autocompletion prompt requires it, so we + // force a TTY by default. + NG_FORCE_TTY: '1', + // Analytics wants to prompt for a first command as well, but we don't care about that here. + NG_CLI_ANALYTICS: 'false', +}); + +export default async function () { + // Sets up autocompletion after user accepts a prompt from any command. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const { stdout } = await execWithEnv( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y' /* stdin: accept prompt */, + ); + + if (!AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('CLI execution did not prompt for autocompletion setup when it should have.'); + } + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + if (!bashrcContents.includes('source <(ng completion script)')) { + throw new Error( + 'Autocompletion was *not* added to `~/.bashrc` after accepting the setup' + ' prompt.', + ); + } + + if (!stdout.includes('Appended `source <(ng completion script)`')) { + throw new Error('CLI did not print that it successfully set up autocompletion.'); + } + }); + + // Does nothing if the user rejects the autocompletion prompt. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const { stdout } = await execWithEnv( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'n' /* stdin: reject prompt */, + ); + + if (!AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('CLI execution did not prompt for autocompletion setup when it should have.'); + } + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + if (bashrcContents.includes('ng completion')) { + throw new Error( + 'Autocompletion was incorrectly added to `~/.bashrc` after refusing the setup' + ' prompt.', + ); + } + + if (stdout.includes('Appended `source <(ng completion script)`')) { + throw new Error( + 'CLI printed that it successfully set up autocompletion when it actually' + " didn't.", + ); + } + + if (!stdout.includes("Ok, you won't be prompted again.")) { + throw new Error('CLI did not inform the user they will not be prompted again.'); + } + }); + + // Does *not* prompt if the user already accepted (even if they delete the completion config). + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + const { stdout: stdout1 } = await execWithEnv( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y' /* stdin: accept prompt */, + ); + + if (!AUTOCOMPLETION_PROMPT.test(stdout1)) { + throw new Error('First execution did not prompt for autocompletion setup.'); + } + + const bashrcContents1 = await fs.readFile(bashrc, 'utf-8'); + if (!bashrcContents1.includes('source <(ng completion script)')) { + throw new Error( + '`~/.bashrc` file was not updated after the user accepted the autocompletion' + + ` prompt. Contents:\n${bashrcContents1}`, + ); + } + + // User modifies their configuration and removes `ng completion`. + await fs.writeFile(bashrc, '# Some new commands...'); + + const { stdout: stdout2 } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout2)) { + throw new Error( + 'Subsequent execution after rejecting autocompletion setup prompted again' + + ' when it should not have.', + ); + } + + const bashrcContents2 = await fs.readFile(bashrc, 'utf-8'); + if (bashrcContents2 !== '# Some new commands...') { + throw new Error( + '`~/.bashrc` file was incorrectly modified when using a modified `~/.bashrc`' + + ` after previously accepting the autocompletion prompt. Contents:\n${bashrcContents2}`, + ); + } + }); + + // Does *not* prompt if the user already rejected. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + const { stdout: stdout1 } = await execWithEnv( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'n' /* stdin: reject prompt */, + ); + + if (!AUTOCOMPLETION_PROMPT.test(stdout1)) { + throw new Error('First execution did not prompt for autocompletion setup.'); + } + + const { stdout: stdout2 } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout2)) { + throw new Error( + 'Subsequent execution after rejecting autocompletion setup prompted again' + + ' when it should not have.', + ); + } + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + if (bashrcContents !== '# Other commands...') { + throw new Error( + '`~/.bashrc` file was incorrectly modified when the user never accepted the' + + ` autocompletion prompt. Contents:\n${bashrcContents}`, + ); + } + }); + + // Prompts user again on subsequent execution after accepting prompt but failing to setup. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + // Make `~/.bashrc` readonly. This is enough for the CLI to verify that the file exists and + // `ng completion` is not in it, but will fail when actually trying to modify the file. + await fs.chmod(bashrc, 0o444); + + const err = await execAndCaptureError( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y' /* stdin: accept prompt */, + ); + + if (!err.message.includes('Failed to append autocompletion setup')) { + throw new Error( + `Failed first execution did not print the expected error message. Actual:\n${err.message}`, + ); + } + + // User corrects file permissions between executions. + await fs.chmod(bashrc, 0o777); + + const { stdout: stdout2 } = await execWithEnv( + 'ng', + ['version'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y' /* stdin: accept prompt */, + ); + + if (!AUTOCOMPLETION_PROMPT.test(stdout2)) { + throw new Error( + 'Subsequent execution after failed autocompletion setup did not prompt again when it should' + + ' have.', + ); + } + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + if (!bashrcContents.includes('ng completion script')) { + throw new Error( + '`~/.bashrc` file does not include `ng completion` after the user never accepted the' + + ` autocompletion prompt a second time. Contents:\n${bashrcContents}`, + ); + } + }); + + // Does *not* prompt for `ng update` commands. + await mockHome(async (home) => { + // Use `ng update --help` so it's actually a no-op and we don't need to setup a project. + const { stdout } = await execWithEnv('ng', ['update', '--help'], { + ...DEFAULT_ENV, + HOME: home, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('`ng update` command incorrectly prompted for autocompletion setup.'); + } + }); + + // Does *not* prompt for `ng completion` commands. + await mockHome(async (home) => { + const { stdout } = await execWithEnv('ng', ['completion'], { + ...DEFAULT_ENV, + HOME: home, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('`ng completion` command incorrectly prompted for autocompletion setup.'); + } + }); + + // Does *not* prompt user for CI executions. + { + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + CI: 'true', + NG_FORCE_TTY: undefined, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('CI execution prompted for autocompletion setup but should not have.'); + } + } + + // Does *not* prompt user for non-TTY executions. + { + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + NG_FORCE_TTY: 'false', + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error('Non-TTY execution prompted for autocompletion setup but should not have.'); + } + } + + // Does *not* prompt user for executions without a `$HOME`. + { + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + HOME: undefined, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error( + 'Execution without a `$HOME` value prompted for autocompletion setup but' + + ' should not have.', + ); + } + } + + // Does *not* prompt user for executions without a `$SHELL`. + { + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + SHELL: undefined, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error( + 'Execution without a `$SHELL` value prompted for autocompletion setup but' + + ' should not have.', + ); + } + } + + // Does *not* prompt user for executions from unknown shells. + { + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + SHELL: '/usr/bin/unknown', + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error( + 'Execution with an unknown `$SHELL` value prompted for autocompletion setup' + + ' but should not have.', + ); + } + } + + // Does *not* prompt user when an RC file already uses `ng completion`. + await mockHome(async (home) => { + await fs.writeFile( + path.join(home, '.bashrc'), + ` +# Some stuff... + +source <(ng completion script) + +# Some other stuff... + `.trim(), + ); + + const { stdout } = await execWithEnv('ng', ['version'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + if (AUTOCOMPLETION_PROMPT.test(stdout)) { + throw new Error( + "Execution with an existing `ng completion` line in the user's RC file" + + ' prompted for autocompletion setup but should not have.', + ); + } + }); +} + +async function mockHome(cb: (home: string) => Promise): Promise { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-e2e-home-')); + + try { + await cb(tempHome); + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + } +} diff --git a/tests/legacy-cli/e2e/tests/misc/completion-script.ts b/tests/legacy-cli/e2e/tests/misc/completion-script.ts new file mode 100644 index 000000000000..0a7cb1daffb5 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/completion-script.ts @@ -0,0 +1,63 @@ +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + // ng build + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'b', ''], + /test-project/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', ''], + /test-project/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', '--a'], + /--aot/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', '--configuration'], + /production/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'b', '--configuration'], + /production/, + ); + + // ng run + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:build\\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:build/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:test/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', 'test-project:build'], + /test-project\\:build\\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', 'test-project:'], + /test-project\\:test/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', 'test-project:build'], + // does not include 'test-project:serve' + /^((?!:serve).)*$/, + ); +} diff --git a/tests/legacy-cli/e2e/tests/misc/completion.ts b/tests/legacy-cli/e2e/tests/misc/completion.ts index 0a7cb1daffb5..52e6bf18ad90 100644 --- a/tests/legacy-cli/e2e/tests/misc/completion.ts +++ b/tests/legacy-cli/e2e/tests/misc/completion.ts @@ -1,63 +1,335 @@ -import { execAndWaitForOutputToMatch } from '../../utils/process'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execAndCaptureError, execAndWaitForOutputToMatch } from '../../utils/process'; export default async function () { - // ng build - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'b', ''], - /test-project/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'build', ''], - /test-project/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'build', '--a'], - /--aot/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'build', '--configuration'], - /production/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'b', '--configuration'], - /production/, - ); - - // ng run - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', ''], - /test-project\\:build\\:development/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', ''], - /test-project\\:build/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', ''], - /test-project\\:test/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', 'test-project:build'], - /test-project\\:build\\:development/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', 'test-project:'], - /test-project\\:test/, - ); - await execAndWaitForOutputToMatch( - 'ng', - ['--get-yargs-completions', 'ng', 'run', 'test-project:build'], - // does not include 'test-project:serve' - /^((?!:serve).)*$/, - ); + // Generates new `.bashrc` file. + await mockHome(async (home) => { + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(path.join(home, '.bashrc'), 'utf-8'); + const expected = ` +# Load Angular CLI autocompletion. +source <(ng completion script) + `.trim(); + if (!rcContents.includes(expected)) { + throw new Error(`~/.bashrc does not contain autocompletion script. Contents:\n${rcContents}`); + } + }); + + // Generates new `.zshrc` file. + await mockHome(async (home) => { + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(path.join(home, '.zshrc'), 'utf-8'); + const expected = ` +# Load Angular CLI autocompletion. +source <(ng completion script) + `.trim(); + if (!rcContents.includes(expected)) { + throw new Error(`~/.zshrc does not contain autocompletion script. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.bashrc` file. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(bashrc, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.bashrc does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.bash_profile` file. + await mockHome(async (home) => { + const bashProfile = path.join(home, '.bash_profile'); + await fs.writeFile(bashProfile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(bashProfile, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.bash_profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.profile` file (using Bash). + await mockHome(async (home) => { + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(profile, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Bash shell prefers `.bashrc`. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# `.bashrc` commands...'); + const bashProfile = path.join(home, '.bash_profile'); + await fs.writeFile(bashProfile, '# `.bash_profile` commands...'); + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# `.profile` commands...'); + + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + 'HOME': home, + }, + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + const bashrcExpected = `# \`.bashrc\` commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (bashrcContents !== bashrcExpected) { + throw new Error(`~/.bashrc does not match expectation. Contents:\n${bashrcContents}`); + } + const bashProfileContents = await fs.readFile(bashProfile, 'utf-8'); + if (bashProfileContents !== '# `.bash_profile` commands...') { + throw new Error( + `~/.bash_profile does not match expectation. Contents:\n${bashProfileContents}`, + ); + } + const profileContents = await fs.readFile(profile, 'utf-8'); + if (profileContents !== '# `.profile` commands...') { + throw new Error(`~/.profile does not match expectation. Contents:\n${profileContents}`); + } + }); + + // Appends to existing `.zshrc` file. + await mockHome(async (home) => { + const zshrc = path.join(home, '.zshrc'); + await fs.writeFile(zshrc, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(zshrc, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.zshrc does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.zsh_profile` file. + await mockHome(async (home) => { + const zshProfile = path.join(home, '.zsh_profile'); + await fs.writeFile(zshProfile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(zshProfile, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.zsh_profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.profile` file (using Zsh). + await mockHome(async (home) => { + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + 'HOME': home, + }, + ); + + const rcContents = await fs.readFile(profile, 'utf-8'); + const expected = `# Other commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (rcContents !== expected) { + throw new Error(`~/.profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Zsh prefers `.zshrc`. + await mockHome(async (home) => { + const zshrc = path.join(home, '.zshrc'); + await fs.writeFile(zshrc, '# `.zshrc` commands...'); + const zshProfile = path.join(home, '.zsh_profile'); + await fs.writeFile(zshProfile, '# `.zsh_profile` commands...'); + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# `.profile` commands...'); + + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + 'HOME': home, + }, + ); + + const zshrcContents = await fs.readFile(zshrc, 'utf-8'); + const zshrcExpected = `# \`.zshrc\` commands... + +# Load Angular CLI autocompletion. +source <(ng completion script) +`; + if (zshrcContents !== zshrcExpected) { + throw new Error(`~/.zshrc does not match expectation. Contents:\n${zshrcContents}`); + } + + const zshProfileContents = await fs.readFile(zshProfile, 'utf-8'); + if (zshProfileContents !== '# `.zsh_profile` commands...') { + throw new Error( + `~/.zsh_profile does not match expectation. Contents:\n${zshProfileContents}`, + ); + } + const profileContents = await fs.readFile(profile, 'utf-8'); + if (profileContents !== '# `.profile` commands...') { + throw new Error(`~/.profile does not match expectation. Contents:\n${profileContents}`); + } + }); + + // Fails for no `$HOME` directory. + { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: '/bin/bash', + HOME: undefined, + }); + if (!err.message.includes('`$HOME` environment variable not set.')) { + throw new Error(`Expected unset \`$HOME\` error message, but got:\n\n${err.message}`); + } + } + + // Fails for no `$SHELL`. + { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: undefined, + }); + if (!err.message.includes('`$SHELL` environment variable not set.')) { + throw new Error(`Expected unset \`$SHELL\` error message, but got:\n\n${err.message}`); + } + } + + // Fails for unknown `$SHELL`. + { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: '/usr/bin/unknown', + }); + if (!err.message.includes('Unknown `$SHELL` environment variable')) { + throw new Error(`Expected unknown \`$SHELL\` error message, but got:\n\n${err.message}`); + } + } +} + +async function mockHome(cb: (home: string) => Promise): Promise { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-e2e-home-')); + + try { + await cb(tempHome); + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + } } diff --git a/tests/legacy-cli/e2e/utils/process.ts b/tests/legacy-cli/e2e/utils/process.ts index bba1e8722623..3db0dff78742 100644 --- a/tests/legacy-cli/e2e/utils/process.ts +++ b/tests/legacy-cli/e2e/utils/process.ts @@ -11,6 +11,7 @@ interface ExecOptions { silent?: boolean; waitForMatch?: RegExp; env?: { [varname: string]: string }; + stdin?: string; } let _processes: child_process.ChildProcess[] = []; @@ -107,6 +108,10 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { + err.message += `${error}...\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}\n`; + reject(err); + }); if (options.waitForMatch) { const match = options.waitForMatch; @@ -123,6 +128,12 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { + try { + await _exec({ env, stdin }, cmd, args); + throw new Error('Tried to capture subprocess exception, but it completed successfully.'); + } catch (err) { + return err; + } +} + +export function execAndWaitForOutputToMatch( + cmd: string, + args: string[], + match: RegExp, + env?: { [varName: string]: string }, +) { if (cmd === 'ng' && args[0] === 'serve') { // Accept matches up to 20 times after the initial match. // Useful because the Webpack watcher can rebuild a few times due to files changes that @@ -186,7 +221,7 @@ export function execAndWaitForOutputToMatch(cmd: string, args: string[], match: // This seems to be due to host file system differences, see // https://nodejs.org/docs/latest/api/fs.html#fs_caveats return concat( - from(_exec({ waitForMatch: match }, cmd, args)), + from(_exec({ waitForMatch: match, env }, cmd, args)), defer(() => waitForAnyProcessOutputToMatch(match, 2500)).pipe( repeat(20), catchError(() => EMPTY), @@ -195,7 +230,7 @@ export function execAndWaitForOutputToMatch(cmd: string, args: string[], match: .pipe(takeLast(1)) .toPromise(); } else { - return _exec({ waitForMatch: match }, cmd, args); + return _exec({ waitForMatch: match, env }, cmd, args); } }