From b5bbe7c334de4f242c751b234884e78cbcde9793 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 28 Apr 2022 18:21:53 -0700 Subject: [PATCH 1/5] refactor: rewritting analytics test to be more hermetic and stable This fixes a few issues with the test: 1. The test accidentally used the real user's `$HOME` directory which led to confusing, non-hermetic failures. 2. The test had timeouts of 5 milliseconds, rather than the presumably intended 5 seconds. 3. `ng update` would always fail because there's no project. Changed to `ng update --help` which still skips the analytics prompt but doesn't require a complicated setup. 4. The test would end after seeing the analytics prompt and wouldn't bother accepting it. I added functionality to send data to stdin to accept the prompt which makes test logic simpler (no need to manually kill all processes or wait for a timeout), though I didn't add any new assertions that the CLI actually tracks / doesn't track correctly. Refs #23003. --- .../e2e/tests/misc/ask-analytics-command.ts | 78 +++++++++++-------- tests/legacy-cli/e2e/utils/process.ts | 20 ++++- 2 files changed, 64 insertions(+), 34 deletions(-) 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..e7bf46d23f93 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,63 @@ -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', + }, + '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', }); - // 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', }); - // 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/utils/process.ts b/tests/legacy-cli/e2e/utils/process.ts index bba1e8722623..85273b5bc795 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 Date: Mon, 25 Apr 2022 19:56:28 -0700 Subject: [PATCH 2/5] feat(@angular/cli): make `ng completion` set up CLI autocompletion by modifying `.bashrc` files `ng completion` is changed to set up Angular CLI autocompletion for the current user by appending `source <(ng completion script)` to their `~/.bashrc`, `~/.bash_profile`, `~/.zshrc`, `~/.zsh_profile`, or `~/.profile`. The previous `ng completion` functionality (printing Yargs autocompletion shell script) is moved to `ng completion script` because most users won't need to worry about this, so we're prioritizing `ng completion` as the part most users will actually type. I couldn't find a good way of testing an error when writing to the `~/.bashrc` file. Since the CLI checks if it has access to the file first, that would usually fail in any circumstance when the file can't be written to. Things could change in between (user modifies file permissions or disk runs out of storage), but there's no easy hook to simulate this change in the e2e test. Refs #23003. --- .../cli/src/commands/completion/cli.ts | 42 +- .../commands/completion/long-description.md | 6 +- .../angular/cli/src/utilities/completion.ts | 75 ++++ .../e2e/tests/misc/completion-script.ts | 63 +++ tests/legacy-cli/e2e/tests/misc/completion.ts | 392 +++++++++++++++--- tests/legacy-cli/e2e/utils/process.ts | 25 +- 6 files changed, 537 insertions(+), 66 deletions(-) create mode 100644 packages/angular/cli/src/utilities/completion.ts create mode 100644 tests/legacy-cli/e2e/tests/misc/completion-script.ts 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/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts new file mode 100644 index 000000000000..314a9ca7cf7a --- /dev/null +++ b/packages/angular/cli/src/utilities/completion.ts @@ -0,0 +1,75 @@ +/** + * @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 { promises as fs } from 'fs'; +import * as path from 'path'; +import { env } from 'process'; + +/** + * 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/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 85273b5bc795..3db0dff78742 100644 --- a/tests/legacy-cli/e2e/utils/process.ts +++ b/tests/legacy-cli/e2e/utils/process.ts @@ -194,7 +194,26 @@ export function execWithEnv( return _exec({ env, stdin }, cmd, args); } -export function execAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { +export async function execAndCaptureError( + cmd: string, + args: string[], + env?: { [varname: string]: string }, + stdin?: 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 @@ -202,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), @@ -211,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); } } From eab987685ea1ef04a49e4ab817aee74519155763 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Fri, 29 Apr 2022 09:55:40 -0700 Subject: [PATCH 3/5] feat(@angular/cli): add prompt to set up CLI autocompletion When the CLI is executed with any command, it will check if `ng completion script` is already included in the user's `~/.bashrc` file (or similar) and if not, ask the user if they would like it to be configured for them. The CLI checks any existing `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zsh_profile`, and `~/.profile` files for `ng completion script`, and if that string is found for the current shell's configuration files, this prompt is skipped. If the user refuses the prompt, no action is taken and the CLI continues on the command the user originally requested. Refs #23003. --- .../cli/src/command-builder/command-module.ts | 9 + .../angular/cli/src/utilities/completion.ts | 108 ++++++++++ .../cli/src/utilities/environment-options.ts | 9 + .../e2e/tests/misc/ask-analytics-command.ts | 3 + .../e2e/tests/misc/completion-prompt.ts | 194 ++++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 tests/legacy-cli/e2e/tests/misc/completion-prompt.ts diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 203e9351fb36..81a13a555922 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,14 @@ export abstract class CommandModule implements CommandModuleI camelCasedOptions[yargsParser.camelCase(key)] = value; } + // Set up autocompletion if appropriate. + const autocompletionExitCode = await considerSettingUpAutocompletion(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/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts index 314a9ca7cf7a..8d7d126faeb1 100644 --- a/packages/angular/cli/src/utilities/completion.ts +++ b/packages/angular/cli/src/utilities/completion.ts @@ -6,9 +6,117 @@ * found in the LICENSE file at https://angular.io/license */ +import { 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 { forceAutocomplete } from '../utilities/environment-options'; +import { isTTY } from '../utilities/tty'; + +/** + * 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( + logger: logging.Logger, +): Promise { + // Check if we should prompt the user to setup autocompletion. + if (!(await shouldPromptForAutocompletionSetup())) { + return undefined; // Already set up, nothing to do. + } + + // Prompt the user and record their response. + const shouldSetupAutocompletion = await promptForAutocompletion(); + if (!shouldSetupAutocompletion) { + return undefined; // User rejected the prompt and doesn't want autocompletion. + } + + // 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(), + ); + + return undefined; +} + +async function shouldPromptForAutocompletionSetup(): Promise { + // Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip. + if (forceAutocomplete !== undefined) { + return forceAutocomplete; + } + + // Non-interactive and continuous integration systems don't care about autocompletion. + if (!isTTY()) { + 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 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 e7bf46d23f93..1cde8a8f0b71 100644 --- a/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts +++ b/tests/legacy-cli/e2e/tests/misc/ask-analytics-command.ts @@ -13,6 +13,7 @@ export default async function () { ...process.env, HOME: home, NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', }, 'y' /* stdin */, ); @@ -29,6 +30,7 @@ export default async function () { HOME: home, NG_FORCE_TTY: '1', NG_CLI_ANALYTICS: 'false', + NG_FORCE_AUTOCOMPLETE: 'false', }); if (ANALYTICS_PROMPT.test(stdout)) { @@ -42,6 +44,7 @@ export default async function () { ...process.env, HOME: home, NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', }); if (ANALYTICS_PROMPT.test(stdout)) { 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..6e57c0960a86 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts @@ -0,0 +1,194 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { env } from 'process'; +import { 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.", + ); + } + }); + + // 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 }); + } +} From aea3706415c2456b52f98d5921ae4098bb5970dc Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Fri, 29 Apr 2022 11:51:30 -0700 Subject: [PATCH 4/5] feat(@angular/cli): remember after prompting users to set up autocompletion and don't prompt again After the user rejects the autocompletion prompt or accepts and is successfully configured, the state is saved into the Angular CLI's global configuration. Before displaying the autocompletion prompt, this state is checked and the prompt is skipped if it was already shown. If the user accepts the prompt but the setup process fails, then the CLI will prompt again on the next execution, this gives users an opportunity to fix whatever issue they are encountering and try again. Refs #23003. --- .../angular/cli/src/commands/config/cli.ts | 2 + .../angular/cli/src/utilities/completion.ts | 64 ++++++- .../e2e/tests/misc/completion-prompt.ts | 157 +++++++++++++++++- 3 files changed, 217 insertions(+), 6 deletions(-) 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 index 8d7d126faeb1..a74ed198f471 100644 --- a/packages/angular/cli/src/utilities/completion.ts +++ b/packages/angular/cli/src/utilities/completion.ts @@ -6,14 +6,24 @@ * found in the LICENSE file at https://angular.io/license */ -import { logging } from '@angular-devkit/core'; +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 @@ -24,14 +34,27 @@ export async function considerSettingUpAutocompletion( logger: logging.Logger, ): Promise { // Check if we should prompt the user to setup autocompletion. - if (!(await shouldPromptForAutocompletionSetup())) { - return undefined; // Already set up, nothing to do. + const completionConfig = await getCompletionConfig(); + if (!(await shouldPromptForAutocompletionSetup(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) { - return undefined; // User rejected the prompt and doesn't want autocompletion. + // 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. @@ -54,10 +77,36 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi `.trim(), ); + // Save configuration to remember that the user was prompted. + await setCompletionConfig({ ...completionConfig, prompted: true }); + return undefined; } -async function shouldPromptForAutocompletionSetup(): Promise { +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(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; @@ -68,6 +117,11 @@ async function shouldPromptForAutocompletionSetup(): Promise { 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) { diff --git a/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts index 6e57c0960a86..a200db185691 100644 --- a/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts +++ b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { env } from 'process'; -import { execWithEnv } from '../../utils/process'; +import { execAndCaptureError, execWithEnv } from '../../utils/process'; const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/; const DEFAULT_ENV = Object.freeze({ @@ -83,6 +83,161 @@ export default async function () { '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 user for CI executions. From 1082963692e9f5be20320d6111f1be16853e5477 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Mon, 2 May 2022 15:51:32 -0700 Subject: [PATCH 5/5] feat(@angular/cli): don't prompt to set up autocompletion for `ng update` and `ng completion` commands `ng update` is most likely called when upgrading a project to the next version and users should be more concerned about their project than their personal terminal setup. `ng completion` is unconditionally setting up autocompletion, while `ng completion script` is getting the shell script for autocompletion setup. As a result, both of these don't benefit from a prompt and should be safe to skip it. --- .../cli/src/command-builder/command-module.ts | 5 +++- .../angular/cli/src/utilities/completion.ts | 13 ++++++++-- .../e2e/tests/misc/completion-prompt.ts | 25 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 81a13a555922..5de4e4e43741 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -125,7 +125,10 @@ export abstract class CommandModule implements CommandModuleI } // Set up autocompletion if appropriate. - const autocompletionExitCode = await considerSettingUpAutocompletion(this.context.logger); + const autocompletionExitCode = await considerSettingUpAutocompletion( + this.commandName, + this.context.logger, + ); if (autocompletionExitCode !== undefined) { process.exitCode = autocompletionExitCode; diff --git a/packages/angular/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts index a74ed198f471..42c006db08ad 100644 --- a/packages/angular/cli/src/utilities/completion.ts +++ b/packages/angular/cli/src/utilities/completion.ts @@ -31,11 +31,12 @@ interface CompletionConfig { * @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(completionConfig))) { + if (!(await shouldPromptForAutocompletionSetup(command, completionConfig))) { return undefined; // Already set up or prompted previously, nothing to do. } @@ -106,12 +107,20 @@ async function setCompletionConfig(config: CompletionConfig): Promise { await wksp.save(); } -async function shouldPromptForAutocompletionSetup(config?: CompletionConfig): Promise { +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; diff --git a/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts index a200db185691..d6ce1c00a56a 100644 --- a/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts +++ b/tests/legacy-cli/e2e/tests/misc/completion-prompt.ts @@ -240,6 +240,31 @@ export default async function () { } }); + // 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'], {