@servicetitan/startup 38.1.0 → 38.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +10 -1
  3. package/dist/cli/commands/build.js.map +1 -1
  4. package/dist/cli/commands/command.d.ts +4 -0
  5. package/dist/cli/commands/command.d.ts.map +1 -1
  6. package/dist/cli/commands/command.js +39 -0
  7. package/dist/cli/commands/command.js.map +1 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +0 -1
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/install.d.ts.map +1 -1
  12. package/dist/cli/commands/install.js +1 -17
  13. package/dist/cli/commands/install.js.map +1 -1
  14. package/dist/cli/commands/registry/command-registry.d.ts +0 -16
  15. package/dist/cli/commands/registry/command-registry.d.ts.map +1 -1
  16. package/dist/cli/commands/registry/install.d.ts +0 -16
  17. package/dist/cli/commands/registry/install.d.ts.map +1 -1
  18. package/dist/cli/commands/registry/install.js +1 -17
  19. package/dist/cli/commands/registry/install.js.map +1 -1
  20. package/dist/cli/commands/start.d.ts.map +1 -1
  21. package/dist/cli/commands/start.js +9 -1
  22. package/dist/cli/commands/start.js.map +1 -1
  23. package/dist/cli/utils/cli-git.d.ts +7 -0
  24. package/dist/cli/utils/cli-git.d.ts.map +1 -1
  25. package/dist/cli/utils/cli-git.js +30 -0
  26. package/dist/cli/utils/cli-git.js.map +1 -1
  27. package/dist/cli/utils/cli-os.d.ts +6 -1
  28. package/dist/cli/utils/cli-os.d.ts.map +1 -1
  29. package/dist/cli/utils/cli-os.js +22 -0
  30. package/dist/cli/utils/cli-os.js.map +1 -1
  31. package/dist/telemetry/index.d.ts +3 -0
  32. package/dist/telemetry/index.d.ts.map +1 -0
  33. package/dist/telemetry/index.js +21 -0
  34. package/dist/telemetry/index.js.map +1 -0
  35. package/dist/telemetry/telemetry.d.ts +15 -0
  36. package/dist/telemetry/telemetry.d.ts.map +1 -0
  37. package/dist/telemetry/telemetry.js +176 -0
  38. package/dist/telemetry/telemetry.js.map +1 -0
  39. package/dist/vite/config/create-filtering-logger.d.ts.map +1 -1
  40. package/dist/vite/config/create-filtering-logger.js +6 -2
  41. package/dist/vite/config/create-filtering-logger.js.map +1 -1
  42. package/dist/webpack/configs/optimization-config.js +13 -8
  43. package/dist/webpack/configs/optimization-config.js.map +1 -1
  44. package/package.json +17 -14
  45. package/src/cli/commands/__tests__/build.test.ts +49 -2
  46. package/src/cli/commands/__tests__/command.test.ts +112 -0
  47. package/src/cli/commands/__tests__/init.test.ts +1 -1
  48. package/src/cli/commands/__tests__/install.test.ts +5 -72
  49. package/src/cli/commands/__tests__/start.test.ts +43 -2
  50. package/src/cli/commands/build.ts +9 -1
  51. package/src/cli/commands/command.ts +50 -0
  52. package/src/cli/commands/init.ts +0 -1
  53. package/src/cli/commands/install.ts +5 -21
  54. package/src/cli/commands/registry/install.ts +1 -6
  55. package/src/cli/commands/start.ts +8 -1
  56. package/src/cli/utils/__tests__/cli-git.test.ts +54 -3
  57. package/src/cli/utils/__tests__/cli-os.test.ts +98 -2
  58. package/src/cli/utils/cli-git.ts +27 -1
  59. package/src/cli/utils/cli-os.ts +30 -0
  60. package/src/telemetry/__tests__/telemetry.test.ts +326 -0
  61. package/src/telemetry/index.ts +2 -0
  62. package/src/telemetry/telemetry.ts +134 -0
  63. package/src/vite/config/__tests__/create-filtering-logger.test.ts +3 -2
  64. package/src/vite/config/create-filtering-logger.ts +8 -2
  65. package/src/webpack/__tests__/create-webpack-config-shared-dependencies.test.ts +27 -1
  66. package/src/webpack/configs/optimization-config.ts +19 -8
@@ -1,6 +1,14 @@
1
+ import { Attributes, telemetry } from '../../../telemetry';
1
2
  import { exitWhenParentExits } from '../../../utils';
2
3
  import { Command } from '../command';
3
4
 
5
+ jest.mock('../../../telemetry', () => ({
6
+ telemetry: {
7
+ info: jest.fn(),
8
+ error: jest.fn(),
9
+ flush: jest.fn(() => Promise.resolve()),
10
+ },
11
+ }));
4
12
  jest.mock('../../../utils', () => ({
5
13
  exitWhenParentExits: jest.fn(),
6
14
  }));
@@ -9,10 +17,21 @@ class MockCommand extends Command {
9
17
  execute() {
10
18
  return Promise.resolve();
11
19
  }
20
+
21
+ recordStart(command: string, attributes?: Attributes): void {
22
+ return super.recordStart(command, attributes);
23
+ }
24
+
25
+ recordEnd(error?: unknown): Promise<void> {
26
+ return super.recordEnd(error);
27
+ }
12
28
  }
13
29
 
14
30
  describe(`[startup] ${Command.name} entity`, () => {
15
31
  const name: any = 'mock-command';
32
+ const commandName = 'build';
33
+ const attributes: Attributes = { bundler: 'vite' };
34
+
16
35
  let args: any;
17
36
 
18
37
  beforeEach(() => {
@@ -39,4 +58,97 @@ describe(`[startup] ${Command.name} entity`, () => {
39
58
  });
40
59
  });
41
60
  });
61
+
62
+ describe('recordStart', () => {
63
+ let command: MockCommand;
64
+
65
+ beforeEach(() => (command = new MockCommand(args)));
66
+
67
+ const subject = () => command.recordStart(commandName, attributes);
68
+
69
+ test('emits a command.started event with the attributes', () => {
70
+ subject();
71
+
72
+ expect(telemetry.info).toHaveBeenCalledWith(
73
+ 'command.started',
74
+ expect.objectContaining({
75
+ ...attributes,
76
+ command: commandName,
77
+ runId: expect.any(String),
78
+ })
79
+ );
80
+ });
81
+
82
+ describe('when a run is already active', () => {
83
+ beforeEach(() => command.recordStart(commandName, attributes));
84
+
85
+ test('does not start a second run', () => {
86
+ subject();
87
+
88
+ expect(telemetry.info).toHaveBeenCalledTimes(1);
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('recordEnd', () => {
94
+ let error: unknown;
95
+ let command: MockCommand;
96
+
97
+ beforeEach(() => {
98
+ error = undefined;
99
+ command = new MockCommand(args);
100
+ });
101
+
102
+ const subject = () => command.recordEnd(error);
103
+
104
+ test('does not emit anything', async () => {
105
+ await subject();
106
+
107
+ expect(telemetry.info).not.toHaveBeenCalled();
108
+ expect(telemetry.error).not.toHaveBeenCalled();
109
+ });
110
+
111
+ describe('when called after recordStart', () => {
112
+ beforeEach(() => command.recordStart(commandName, attributes));
113
+
114
+ test('emits a command.finished event with a duration', async () => {
115
+ await subject();
116
+
117
+ expect(telemetry.info).toHaveBeenCalledWith(
118
+ 'command.finished',
119
+ expect.objectContaining({
120
+ ...attributes,
121
+ command: commandName,
122
+ runId: expect.any(String),
123
+ duration: expect.any(Number),
124
+ })
125
+ );
126
+ });
127
+
128
+ test('flushes pending telemetry', async () => {
129
+ await subject();
130
+
131
+ expect(telemetry.flush).toHaveBeenCalled();
132
+ });
133
+
134
+ describe('with an error', () => {
135
+ beforeEach(() => (error = new Error('boom')));
136
+
137
+ test('emits a command.finished error event', async () => {
138
+ await subject();
139
+
140
+ expect(telemetry.error).toHaveBeenCalledWith(
141
+ 'command.finished',
142
+ error,
143
+ expect.objectContaining({
144
+ ...attributes,
145
+ command: commandName,
146
+ runId: expect.any(String),
147
+ duration: expect.any(Number),
148
+ })
149
+ );
150
+ });
151
+ });
152
+ });
153
+ });
42
154
  });
@@ -40,7 +40,7 @@ describe(`[startup] ${Init.name}`, () => {
40
40
  force: true,
41
41
  });
42
42
  expect(fs.rmSync).toHaveBeenCalledWith(path.join(cwd, '.github', 'CODEOWNERS'));
43
- expect(fs.rmSync).toHaveBeenCalledWith(path.join(cwd, 'package-lock.json'));
43
+ expect(fs.rmSync).not.toHaveBeenCalledWith(path.join(cwd, 'package-lock.json'));
44
44
  });
45
45
 
46
46
  describe('when cloning fails', () => {
@@ -1,83 +1,16 @@
1
- import { execSync } from 'child_process';
2
- import { getStartupVersion } from '../../../core';
3
- import { log } from '../../../utils';
4
1
  import { Install } from '../install';
5
2
  import { entry } from '../registry/install';
6
3
 
7
- jest.mock('child_process', () => ({ execSync: jest.fn() }));
8
- jest.mock('../../../core', () => ({
9
- ...jest.requireActual('../../../core'),
10
- getStartupVersion: jest.fn(),
11
- }));
12
- jest.mock('../../../utils', () => ({
13
- ...jest.requireActual('../../../utils'),
14
- log: { debug: jest.fn(), info: jest.fn() }, // suppress log output
15
- }));
16
-
17
4
  describe(`${Install.name}`, () => {
18
- const startupVersion = '1.2.3';
19
- let args: ConstructorParameters<typeof Install>[0];
20
-
21
- beforeEach(() => {
22
- args = {};
23
- jest.clearAllMocks();
24
- jest.mocked(getStartupVersion).mockReturnValue(startupVersion);
25
- });
26
-
27
- const subject = async () => new Install(args).execute();
5
+ const subject = async () => new Install({}).execute();
28
6
 
29
7
  test('allows running from global location', () => {
30
8
  expect(entry.allowRunFromGlobal).toBe(true);
31
9
  });
32
10
 
33
- test('runs install', async () => {
34
- await subject();
35
-
36
- expect(execSync).toHaveBeenCalledWith(`npx --yes @servicetitan/install`, {
37
- stdio: 'inherit',
38
- });
39
- });
40
-
41
- const keys = ['fix', 'quiet', 'token'] satisfies (keyof typeof args)[];
42
- describe.each(keys)('with --%s', option => {
43
- beforeEach(() => (args[option] = true));
44
-
45
- test(`runs install with --${option}`, async () => {
46
- await subject();
47
-
48
- expect(execSync).toHaveBeenCalledWith(
49
- expect.stringContaining(`--${option}`),
50
- expect.anything()
51
- );
52
- });
53
- });
54
-
55
- describe('with --no-token', () => {
56
- beforeEach(() => (args.token = false));
57
-
58
- test('runs install with --no-token', async () => {
59
- await subject();
60
-
61
- expect(execSync).toHaveBeenCalledWith(
62
- expect.stringContaining('--no-token'),
63
- expect.anything()
64
- );
65
- });
66
- });
67
-
68
- test('logs progress', async () => {
69
- await subject();
70
-
71
- expect(log.info).toHaveBeenCalledWith(`startup cli v${startupVersion}`);
72
- });
73
-
74
- describe('with --quiet', () => {
75
- beforeEach(() => (args = { quiet: true }));
76
-
77
- test('does not log progress', async () => {
78
- await subject();
79
-
80
- expect(log.info).not.toHaveBeenCalled();
81
- });
11
+ test('throws guidance pointing to @servicetitan/install', async () => {
12
+ await expect(subject()).rejects.toThrow(
13
+ 'The `startup install` command has been removed. Run `npx --yes @servicetitan/install` directly instead.'
14
+ );
82
15
  });
83
16
  });
@@ -1,6 +1,8 @@
1
1
  import { createPackage } from '../../../__mocks__';
2
- import { Package, PackageType, getConfiguration, getPackages, log } from '../../../utils';
3
- import { compile, lernaExec, typeCheck, watchStdout } from '../../utils';
2
+ import { Attributes } from '../../../telemetry';
3
+ import { getConfiguration, getPackages, log, Package, PackageType } from '../../../utils';
4
+ import { compile, lernaExec, ProcessTree, typeCheck, watchStdout } from '../../utils';
5
+ import { Command } from '../command';
4
6
  import { entry } from '../registry/start';
5
7
  import { Start } from '../start';
6
8
 
@@ -31,12 +33,20 @@ jest.mock('../../utils', () => ({
31
33
  }));
32
34
 
33
35
  describe(`[startup] ${Start.name}`, () => {
36
+ const commandProto = Command.prototype as unknown as {
37
+ recordStart: (command: string, attributes?: Attributes) => void;
38
+ recordEnd: (error?: unknown) => Promise<void>;
39
+ };
40
+
34
41
  let args: ConstructorParameters<typeof Start>[0];
35
42
  let packages: Package[];
43
+ let recordStart: jest.SpyInstance;
44
+ let recordEnd: jest.SpyInstance;
36
45
 
37
46
  beforeEach(() => {
38
47
  args = {};
39
48
  packages = [];
49
+ jest.restoreAllMocks();
40
50
  jest.clearAllMocks();
41
51
  jest.mocked(getConfiguration).mockReturnValue({});
42
52
  jest.mocked(getPackages).mockImplementation(() => packages);
@@ -44,6 +54,8 @@ describe(`[startup] ${Start.name}`, () => {
44
54
  handler(packages.length);
45
55
  return jest.fn();
46
56
  });
57
+ recordStart = jest.spyOn(commandProto, 'recordStart').mockImplementation(() => {});
58
+ recordEnd = jest.spyOn(commandProto, 'recordEnd').mockResolvedValue(undefined);
47
59
  });
48
60
 
49
61
  const subject = () => new Start(args).execute();
@@ -113,6 +125,35 @@ describe(`[startup] ${Start.name}`, () => {
113
125
  expect(process.env.STARTUP_PARENT_PID).toBe(String(process.pid));
114
126
  });
115
127
 
128
+ describe('telemetry', () => {
129
+ beforeEach(() => packages.push(createPackage({ type: PackageType.TSC })));
130
+
131
+ test('records the command start', async () => {
132
+ await subject();
133
+
134
+ expect(recordStart).toHaveBeenCalledWith('start', { bundler: expect.any(String) });
135
+ });
136
+
137
+ describe('when start fails', () => {
138
+ const error = new Error('watch failed');
139
+
140
+ beforeEach(() => {
141
+ jest.spyOn(log, 'error').mockImplementation(jest.fn());
142
+ jest.spyOn(ProcessTree.prototype, 'run').mockRejectedValue(error);
143
+ });
144
+
145
+ test('records the failure', async () => {
146
+ await subject().catch(() => {});
147
+
148
+ expect(recordEnd).toHaveBeenCalledWith(error);
149
+ });
150
+
151
+ test('propagates the error', async () => {
152
+ await expect(subject()).rejects.toBe(error);
153
+ });
154
+ });
155
+ });
156
+
116
157
  describe('with TSC package', () => {
117
158
  beforeEach(() => packages.push(createPackage({ type: PackageType.TSC })));
118
159
 
@@ -30,6 +30,8 @@ export class Build extends Command<typeof entry> {
30
30
  return;
31
31
  }
32
32
 
33
+ this.recordStart('build', { bundler: resolveBundler(this.args) });
34
+
33
35
  if (this.args.cdnPath) {
34
36
  process.env.CLIENT_CDN_PATH = this.args.cdnPath;
35
37
  }
@@ -115,6 +117,12 @@ export class Build extends Command<typeof entry> {
115
117
  { dependsOn: [BuildProcesses.BundleExposedDependencies] }
116
118
  );
117
119
 
118
- await processTree.run();
120
+ try {
121
+ await processTree.run();
122
+ } catch (error) {
123
+ await this.recordEnd(error);
124
+ throw error;
125
+ }
126
+ this.recordEnd();
119
127
  }
120
128
  }
@@ -1,7 +1,20 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { Attributes, telemetry } from '../../telemetry';
1
3
  import { CommandName, exitWhenParentExits } from '../../utils';
2
4
  import { CommandEntry, InferArgs } from './types';
3
5
 
6
+ const NS_PER_MS = 1_000_000;
7
+
8
+ interface CommandRun {
9
+ command: string;
10
+ runId: string;
11
+ startedAt: number;
12
+ attributes?: Attributes;
13
+ }
14
+
4
15
  export abstract class Command<T extends CommandEntry = CommandEntry, U = unknown> {
16
+ private commandRun?: CommandRun;
17
+
5
18
  constructor(protected readonly args: InferArgs<NonNullable<T['options']>, U>) {}
6
19
 
7
20
  watchForParentExit(name: CommandName): void {
@@ -10,5 +23,42 @@ export abstract class Command<T extends CommandEntry = CommandEntry, U = unknown
10
23
  }
11
24
  }
12
25
 
26
+ protected recordStart(command: string, attributes?: Attributes): void {
27
+ if (this.commandRun) {
28
+ return;
29
+ }
30
+ this.commandRun = {
31
+ command,
32
+ runId: randomUUID(),
33
+ startedAt: globalThis.performance.now(),
34
+ attributes,
35
+ };
36
+ telemetry.info('command.started', {
37
+ ...attributes,
38
+ command,
39
+ runId: this.commandRun.runId,
40
+ });
41
+ }
42
+
43
+ protected recordEnd(error?: unknown): Promise<void> {
44
+ const run = this.commandRun;
45
+ if (!run) {
46
+ return Promise.resolve();
47
+ }
48
+ this.commandRun = undefined;
49
+ const attributes = {
50
+ ...run.attributes,
51
+ command: run.command,
52
+ runId: run.runId,
53
+ duration: Math.round((globalThis.performance.now() - run.startedAt) * NS_PER_MS),
54
+ };
55
+ if (error !== undefined) {
56
+ telemetry.error('command.finished', error, attributes);
57
+ } else {
58
+ telemetry.info('command.finished', attributes);
59
+ }
60
+ return telemetry.flush();
61
+ }
62
+
13
63
  abstract execute(): Promise<void>;
14
64
  }
@@ -39,7 +39,6 @@ export class Init extends Command<typeof entry> {
39
39
 
40
40
  fs.rmSync(path.join(destination, '.git'), { recursive: true, force: true });
41
41
  fs.rmSync(path.join(destination, '.github', 'CODEOWNERS'));
42
- fs.rmSync(path.join(destination, 'package-lock.json'));
43
42
  return true;
44
43
  }
45
44
  }
@@ -1,29 +1,13 @@
1
- import { execSync } from 'child_process';
2
- import { getStartupVersion } from '../../core';
3
- import { log, logErrors } from '../../utils';
1
+ import { logErrors } from '../../utils';
4
2
  import type { entry } from './registry/install';
5
3
  import { Command } from './types';
6
4
 
7
5
  export class Install extends Command<typeof entry> {
8
6
  @logErrors
7
+ // eslint-disable-next-line @typescript-eslint/require-await
9
8
  async execute() {
10
- if (!this.args.quiet) {
11
- log.info(`startup cli v${getStartupVersion()}`);
12
- }
13
-
14
- const options = [
15
- this.args.fix ? '--fix' : '',
16
- this.args.quiet ? '--quiet' : '',
17
- this.args.token === true ? '--token' : '',
18
- this.args.token === false ? '--no-token' : '',
19
- ].filter(option => !!option);
20
-
21
- const command = `npx --yes @servicetitan/install ${options.join(' ')}`.trim();
22
-
23
- log.debug('install', command);
24
-
25
- execSync(command, { stdio: 'inherit' });
26
-
27
- return Promise.resolve(); // stops "async method has no 'await' expression" lint error
9
+ throw new Error(
10
+ 'The `startup install` command has been removed. Run `npx --yes @servicetitan/install` directly instead.'
11
+ );
28
12
  }
29
13
  }
@@ -3,10 +3,5 @@ import { defineEntry } from './define-entry';
3
3
 
4
4
  export const entry = defineEntry({
5
5
  allowRunFromGlobal: true,
6
- description: 'Install project dependencies',
7
- options: {
8
- fix: { boolean: true, description: 'Update and dedupe package-lock.json', hidden: true },
9
- quiet: { boolean: true, description: 'Suppress output', hidden: true },
10
- token: { boolean: true, description: 'Configure npm token' },
11
- },
6
+ description: 'Removed. Run `npx --yes @servicetitan/install` directly instead.',
12
7
  } satisfies CommandEntry);
@@ -42,6 +42,8 @@ export class Start extends Command<typeof entry> {
42
42
  return;
43
43
  }
44
44
 
45
+ this.recordStart('start', { bundler: resolveBundler(this.args) });
46
+
45
47
  const scope = packages.map(({ name }) => name);
46
48
  const bundleScope = packages
47
49
  .filter(({ type }) => type === PackageType.Bundle)
@@ -160,6 +162,11 @@ export class Start extends Command<typeof entry> {
160
162
  { dependsOn: [StartProcesses.BundleExposedDependencies] }
161
163
  );
162
164
 
163
- await processTree.run();
165
+ try {
166
+ await processTree.run();
167
+ } catch (error) {
168
+ await this.recordEnd(error);
169
+ throw error;
170
+ }
164
171
  }
165
172
  }
@@ -1,7 +1,7 @@
1
- import { gitGetBranch, gitGetCommitHash } from '../cli-git';
2
- import { runCommandOutput } from '../cli-os';
1
+ import { gitGetBranch, gitGetCommitHash, gitGetContext } from '../cli-git';
2
+ import { runCommandOutput, runCommandOutputAsync } from '../cli-os';
3
3
 
4
- jest.mock('../cli-os', () => ({ runCommandOutput: jest.fn() }));
4
+ jest.mock('../cli-os', () => ({ runCommandOutput: jest.fn(), runCommandOutputAsync: jest.fn() }));
5
5
 
6
6
  describe('[startup] Cli Utils (Git)', () => {
7
7
  beforeEach(() => {
@@ -26,4 +26,55 @@ describe('[startup] Cli Utils (Git)', () => {
26
26
  describe(gitGetCommitHash.name, () => {
27
27
  itRunsCommand(gitGetCommitHash, 'git rev-parse --short HEAD');
28
28
  });
29
+
30
+ describe(gitGetContext.name, () => {
31
+ const root = '/repos/monolith';
32
+ const context = {
33
+ repo: 'monolith',
34
+ branch: 'main',
35
+ userEmail: 'dev@st.com',
36
+ userName: 'dev',
37
+ };
38
+
39
+ let output: Record<string, string | undefined>;
40
+
41
+ beforeEach(() => {
42
+ output = {
43
+ 'rev-parse': `${context.branch}\n${root}`,
44
+ 'user.email': context.userEmail,
45
+ 'user.name': context.userName,
46
+ };
47
+ jest.mocked(runCommandOutputAsync).mockImplementation(command => {
48
+ const key = Object.keys(output).find(entry => String(command).includes(entry));
49
+ return Promise.resolve(key ? output[key] : undefined);
50
+ });
51
+ });
52
+
53
+ const subject = () => gitGetContext();
54
+
55
+ test('collects the repo, branch, and user', async () => {
56
+ expect(await subject()).toEqual(context);
57
+ });
58
+
59
+ describe('when one command fails', () => {
60
+ beforeEach(() => (output['user.email'] = undefined));
61
+
62
+ test('leaves only that field undefined', async () => {
63
+ expect(await subject()).toEqual({ ...context, userEmail: undefined });
64
+ });
65
+ });
66
+
67
+ describe('when every command fails', () => {
68
+ beforeEach(() => (output = {}));
69
+
70
+ test('leaves the fields undefined', async () => {
71
+ expect(await subject()).toEqual({
72
+ repo: undefined,
73
+ branch: undefined,
74
+ userEmail: undefined,
75
+ userName: undefined,
76
+ });
77
+ });
78
+ });
79
+ });
29
80
  });
@@ -1,8 +1,9 @@
1
- import { execFileSync, execSync, spawn } from 'child_process';
1
+ import { exec, execFileSync, execSync, spawn } from 'child_process';
2
2
  import { log } from '../../../utils';
3
- import { killProcessTree, runCommand, runCommandOutput } from '../cli-os';
3
+ import { killProcessTree, runCommand, runCommandOutput, runCommandOutputAsync } from '../cli-os';
4
4
 
5
5
  jest.mock('child_process', () => ({
6
+ exec: jest.fn(),
6
7
  execFileSync: jest.fn(),
7
8
  execSync: jest.fn(),
8
9
  spawn: jest.fn(),
@@ -143,6 +144,101 @@ describe('[startup] Cli Utils (OS)', () => {
143
144
  });
144
145
  });
145
146
 
147
+ describe(`${runCommandOutputAsync.name}`, () => {
148
+ // promisify(exec) resolves with { stdout, stderr }; the mock supplies that shape.
149
+ const mockExec = (
150
+ impl: (callback: (error: Error | null, result?: { stdout: string }) => void) => void
151
+ ) =>
152
+ jest
153
+ .mocked(exec)
154
+ .mockImplementation(((
155
+ _command: string,
156
+ _options: unknown,
157
+ callback: (error: Error | null, result?: { stdout: string }) => void
158
+ ) => impl(callback)) as unknown as typeof exec);
159
+
160
+ let command: string;
161
+ let options: NonNullable<Parameters<typeof runCommandOutputAsync>[1]>;
162
+
163
+ beforeEach(() => {
164
+ jest.clearAllMocks();
165
+ command = 'foo';
166
+ options = {};
167
+ mockExec(callback => callback(null, { stdout: 'result\n' }));
168
+ });
169
+
170
+ const subject = () => runCommandOutputAsync(command, options);
171
+
172
+ test('resolves with the trimmed result', async () => {
173
+ await expect(subject()).resolves.toBe('result');
174
+ });
175
+
176
+ test('runs the command', async () => {
177
+ await subject();
178
+
179
+ expect(exec).toHaveBeenCalledWith(command, options, expect.any(Function));
180
+ });
181
+
182
+ describe('when the output is empty', () => {
183
+ beforeEach(() => mockExec(callback => callback(null, { stdout: ' ' })));
184
+
185
+ test('resolves with undefined', async () => {
186
+ await expect(subject()).resolves.toBeUndefined();
187
+ });
188
+ });
189
+
190
+ describe('with options', () => {
191
+ beforeEach(() => (options.timeout = 10000));
192
+
193
+ test('passes them through', async () => {
194
+ await subject();
195
+
196
+ expect(exec).toHaveBeenCalledWith(command, options, expect.any(Function));
197
+ });
198
+ });
199
+
200
+ test('logs the command', async () => {
201
+ const logInfoSpy = jest.spyOn(log, 'info');
202
+ await subject();
203
+
204
+ expect(logInfoSpy).toHaveBeenCalledWith(`Running: ${command}`);
205
+ });
206
+
207
+ test('logs the result', async () => {
208
+ const logInfoSpy = jest.spyOn(log, 'info');
209
+ await subject();
210
+
211
+ expect(logInfoSpy).toHaveBeenCalledWith('command finished', 'result');
212
+ });
213
+
214
+ describe('with quiet', () => {
215
+ beforeEach(() => (options.quiet = true));
216
+
217
+ test('suppresses output', async () => {
218
+ const logInfoSpy = jest.spyOn(log, 'info');
219
+ await subject();
220
+
221
+ expect(logInfoSpy).not.toHaveBeenCalled();
222
+ });
223
+ });
224
+
225
+ describe('when the command fails', () => {
226
+ beforeEach(() => mockExec(callback => callback(new Error('boom'))));
227
+
228
+ test('rejects', async () => {
229
+ await expect(subject()).rejects.toThrow('boom');
230
+ });
231
+
232
+ describe('with ignoreErrors', () => {
233
+ beforeEach(() => (options.ignoreErrors = true));
234
+
235
+ test('resolves with undefined', async () => {
236
+ await expect(subject()).resolves.toBeUndefined();
237
+ });
238
+ });
239
+ });
240
+ });
241
+
146
242
  describe(`${killProcessTree.name}`, () => {
147
243
  const pid = 1234;
148
244
  const originalPlatform = process.platform;