|
1 | | -import { describe, expect, it, afterEach } from '@jest/globals'; |
| 1 | +import { describe, expect, it, afterEach, jest, beforeEach } from '@jest/globals'; |
2 | 2 | import fs from 'fs-extra'; |
| 3 | +import type prompts from 'prompts'; |
3 | 4 | import { getChangeFiles } from '../__fixtures__/changeFiles'; |
4 | 5 | import { initMockLogs } from '../__fixtures__/mockLogs'; |
5 | | -import { RepositoryFactory } from '../__fixtures__/repositoryFactory'; |
| 6 | +import { RepoFixture, RepositoryFactory } from '../__fixtures__/repositoryFactory'; |
6 | 7 | import { change } from '../commands/change'; |
7 | 8 | import { BeachballOptions } from '../types/BeachballOptions'; |
8 | 9 | import { defaultBranchName } from '../__fixtures__/gitDefaults'; |
| 10 | +import { MockStdout } from '../__fixtures__/mockStdout'; |
| 11 | +import { MockStdin } from '../__fixtures__/mockStdin'; |
| 12 | +import { ChangeFileInfo } from '../types/ChangeInfo'; |
| 13 | +import { Repository } from '../__fixtures__/repository'; |
| 14 | + |
| 15 | +// prompts writes to stdout (not console) in a way that can't really be mocked with spies, |
| 16 | +// so instead we inject a custom mock stdout stream, as well as stdin for entering answers |
| 17 | +let stdin: MockStdin; |
| 18 | +let stdout: MockStdout; |
| 19 | +jest.mock( |
| 20 | + 'prompts', |
| 21 | + (): typeof prompts => |
| 22 | + ((questions, options) => { |
| 23 | + questions = Array.isArray(questions) ? questions : [questions]; |
| 24 | + questions = questions.map(q => ({ ...q, stdin, stdout })); |
| 25 | + return (jest.requireActual('prompts') as typeof prompts)(questions, options); |
| 26 | + }) as typeof prompts |
| 27 | +); |
| 28 | + |
| 29 | +/** |
| 30 | + * Inject these options into `PackageInfo.combinedOptions` for every package to simulate a |
| 31 | + * repo-wide config. (Actual repo-wide configs aren't usually read in tests because the current |
| 32 | + * implementation depends on the actual cwd, not the temp repo directory.) |
| 33 | + */ |
| 34 | +let mockBeachballOptions: Partial<BeachballOptions> | undefined; |
| 35 | +jest.mock('../options/getDefaultOptions', () => ({ |
| 36 | + getDefaultOptions: () => ({ |
| 37 | + ...(jest.requireActual('../options/getDefaultOptions') as any).getDefaultOptions(), |
| 38 | + ...mockBeachballOptions, |
| 39 | + }), |
| 40 | +})); |
| 41 | + |
| 42 | +/** Wait for the prompt to finish rendering (simulates real user input) */ |
| 43 | +const waitForPrompt = () => new Promise(resolve => process.nextTick(resolve)); |
| 44 | + |
| 45 | +const monorepo: RepoFixture['folders'] = { |
| 46 | + packages: { 'pkg-1': { version: '1.0.0' }, 'pkg-2': { version: '1.0.0' }, 'pkg-3': { version: '1.0.0' } }, |
| 47 | +}; |
| 48 | + |
| 49 | +function makeMonorepoChanges(repo: Repository) { |
| 50 | + repo.checkout('-b', 'test'); |
| 51 | + repo.stageChange('packages/pkg-1/file.js'); |
| 52 | + repo.commitAll('commit 1'); |
| 53 | + repo.stageChange('packages/pkg-2/file.js'); |
| 54 | + repo.commitAll('commit 2'); |
| 55 | +} |
9 | 56 |
|
10 | 57 | describe('change command', () => { |
11 | 58 | let repositoryFactory: RepositoryFactory | undefined; |
12 | 59 |
|
13 | | - initMockLogs(); |
| 60 | + const logs = initMockLogs(); |
| 61 | + |
| 62 | + beforeEach(() => { |
| 63 | + stdin = new MockStdin(); |
| 64 | + stdout = new MockStdout({ replace: 'prompts' }); |
| 65 | + }); |
14 | 66 |
|
15 | 67 | afterEach(() => { |
16 | | - if (repositoryFactory) { |
17 | | - repositoryFactory.cleanUp(); |
18 | | - repositoryFactory = undefined; |
19 | | - } |
| 68 | + stdin.destroy(); |
| 69 | + stdout.destroy(); |
| 70 | + repositoryFactory?.cleanUp(); |
| 71 | + repositoryFactory = undefined; |
| 72 | + mockBeachballOptions = undefined; |
| 73 | + }); |
| 74 | + |
| 75 | + it('does not create change files when there are no changes', async () => { |
| 76 | + repositoryFactory = new RepositoryFactory('single'); |
| 77 | + const repo = repositoryFactory.cloneRepository(); |
| 78 | + |
| 79 | + await change({ path: repo.rootPath, branch: defaultBranchName } as BeachballOptions); |
| 80 | + |
| 81 | + expect(getChangeFiles(repo.rootPath)).toHaveLength(0); |
| 82 | + }); |
| 83 | + |
| 84 | + it('creates and stages a change file', async () => { |
| 85 | + repositoryFactory = new RepositoryFactory('single'); |
| 86 | + const repo = repositoryFactory.cloneRepository(); |
| 87 | + |
| 88 | + repo.checkout('-b', 'test'); |
| 89 | + repo.commitChange('file.js'); |
| 90 | + |
| 91 | + const changePromise = change({ path: repo.rootPath, branch: defaultBranchName, commit: false } as BeachballOptions); |
| 92 | + await waitForPrompt(); |
| 93 | + |
| 94 | + // Use default change type and custom message |
| 95 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: foo'); |
| 96 | + await stdin.sendByChar('\n'); |
| 97 | + // Also verify that the options shown are correct |
| 98 | + expect(stdout.lastOutput()).toMatchInlineSnapshot(` |
| 99 | + "? Describe changes (type or choose one) » |
| 100 | + > \\"file.js\\"" |
| 101 | + `); |
| 102 | + await stdin.sendByChar('stage me please\n'); |
| 103 | + await changePromise; |
| 104 | + |
| 105 | + expect(repo.status()).toMatch(/^A change/); |
| 106 | + expect(logs.mocks.log).toHaveBeenLastCalledWith(expect.stringMatching(/^git staged these change files:/)); |
| 107 | + |
| 108 | + const changeFiles = getChangeFiles(repo.rootPath); |
| 109 | + expect(changeFiles).toHaveLength(1); |
| 110 | + expect(fs.readJSONSync(changeFiles[0])).toMatchObject({ |
| 111 | + comment: 'stage me please', |
| 112 | + packageName: 'foo', |
| 113 | + type: 'patch', |
| 114 | + }); |
20 | 115 | }); |
21 | 116 |
|
22 | | - it('create change file but git stage only', async () => { |
| 117 | + it('creates and commits a change file', async () => { |
23 | 118 | repositoryFactory = new RepositoryFactory('single'); |
24 | 119 | const repo = repositoryFactory.cloneRepository(); |
25 | 120 |
|
26 | | - await change({ |
27 | | - type: 'minor', |
28 | | - dependentChangeType: 'patch', |
| 121 | + repo.checkout('-b', 'test'); |
| 122 | + repo.commitChange('file.js'); |
| 123 | + |
| 124 | + const changePromise = change({ path: repo.rootPath, branch: defaultBranchName } as BeachballOptions); |
| 125 | + |
| 126 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: foo'); |
| 127 | + await stdin.sendByChar('\n'); // default change type |
| 128 | + await stdin.sendByChar('commit me please\n'); // custom message |
| 129 | + await changePromise; |
| 130 | + |
| 131 | + expect(logs.mocks.log).toHaveBeenLastCalledWith(expect.stringMatching(/^git committed these change files:/)); |
| 132 | + expect(repo.status()).toBe(''); |
| 133 | + |
| 134 | + const changeFiles = getChangeFiles(repo.rootPath); |
| 135 | + expect(changeFiles).toHaveLength(1); |
| 136 | + expect(fs.readJSONSync(changeFiles[0])).toMatchObject({ |
| 137 | + comment: 'commit me please', |
| 138 | + packageName: 'foo', |
| 139 | + type: 'patch', |
| 140 | + }); |
| 141 | + }); |
| 142 | + |
| 143 | + it('creates a change file when there are no changes but package name is provided', async () => { |
| 144 | + repositoryFactory = new RepositoryFactory('single'); |
| 145 | + const repo = repositoryFactory.cloneRepository(); |
| 146 | + |
| 147 | + const changePromise = change({ |
29 | 148 | package: repositoryFactory.fixture.rootPackage!.name, |
30 | | - message: 'stage me please', |
31 | 149 | path: repo.rootPath, |
32 | 150 | branch: defaultBranchName, |
33 | 151 | commit: false, |
34 | 152 | } as BeachballOptions); |
| 153 | + await waitForPrompt(); |
| 154 | + |
| 155 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: foo'); |
| 156 | + await stdin.sendByChar('\n'); // default change type |
| 157 | + await stdin.sendByChar('stage me please\n'); // custom message |
| 158 | + await changePromise; |
35 | 159 |
|
36 | 160 | expect(repo.status()).toMatch(/^A change/); |
37 | 161 |
|
38 | 162 | const changeFiles = getChangeFiles(repo.rootPath); |
39 | 163 | expect(changeFiles).toHaveLength(1); |
40 | 164 | }); |
41 | 165 |
|
42 | | - it('create change file but git stage only multiple changes', async () => { |
43 | | - repositoryFactory = new RepositoryFactory({ |
44 | | - folders: { |
45 | | - packages: { |
46 | | - 'pkg-1': { version: '1.0.0' }, |
47 | | - 'pkg-2': { version: '2.0.0' }, |
48 | | - }, |
49 | | - }, |
50 | | - }); |
| 166 | + it('creates and commits change files for multiple packages', async () => { |
| 167 | + repositoryFactory = new RepositoryFactory({ folders: monorepo }); |
| 168 | + const repo = repositoryFactory.cloneRepository(); |
| 169 | + makeMonorepoChanges(repo); |
| 170 | + |
| 171 | + const changePromise = change({ path: repo.rootPath, branch: defaultBranchName } as BeachballOptions); |
| 172 | + |
| 173 | + // use custom values for first package |
| 174 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-1'); |
| 175 | + stdin.emitKey({ name: 'down' }); |
| 176 | + await stdin.sendByChar('\n'); |
| 177 | + // also verify that the options shown are correct |
| 178 | + expect(stdout.lastOutput()).toMatchInlineSnapshot(` |
| 179 | + "? Describe changes (type or choose one) » |
| 180 | + > commit 2 |
| 181 | + commit 1" |
| 182 | + `); |
| 183 | + await stdin.sendByChar('custom\n'); |
| 184 | + |
| 185 | + // use defaults for second package |
| 186 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-2'); |
| 187 | + await stdin.sendByChar('\n\n'); |
| 188 | + |
| 189 | + await changePromise; |
| 190 | + |
| 191 | + expect(logs.mocks.log).toHaveBeenLastCalledWith(expect.stringMatching(/^git committed these change files:/)); |
| 192 | + expect(repo.status()).toBe(''); |
| 193 | + |
| 194 | + const changeFiles = getChangeFiles(repo.rootPath); |
| 195 | + expect(changeFiles).toHaveLength(2); |
| 196 | + const changeFileContents = changeFiles.map(changeFile => fs.readJSONSync(changeFile)) as ChangeFileInfo[]; |
| 197 | + expect(changeFileContents).toContainEqual( |
| 198 | + expect.objectContaining({ comment: 'custom', packageName: 'pkg-1', type: 'minor' }) |
| 199 | + ); |
| 200 | + expect(changeFileContents).toContainEqual( |
| 201 | + expect.objectContaining({ comment: 'commit 2', packageName: 'pkg-2', type: 'patch' }) |
| 202 | + ); |
| 203 | + }); |
| 204 | + |
| 205 | + it('creates and commits grouped change file for multiple packages', async () => { |
| 206 | + repositoryFactory = new RepositoryFactory({ folders: monorepo }); |
51 | 207 | const repo = repositoryFactory.cloneRepository(); |
| 208 | + makeMonorepoChanges(repo); |
52 | 209 |
|
53 | | - await change({ |
54 | | - type: 'minor', |
55 | | - dependentChangeType: 'patch', |
56 | | - package: ['pkg-1', 'pkg-2'], |
57 | | - message: 'stage me please', |
| 210 | + const changePromise = change({ |
58 | 211 | path: repo.rootPath, |
59 | 212 | branch: defaultBranchName, |
60 | | - commit: false, |
61 | 213 | groupChanges: true, |
62 | 214 | } as BeachballOptions); |
63 | 215 |
|
64 | | - expect(repo.status()).toMatch(/^A change/); |
| 216 | + // use custom values for first package |
| 217 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-1'); |
| 218 | + stdin.emitKey({ name: 'down' }); |
| 219 | + await stdin.sendByChar('\n'); |
| 220 | + await stdin.sendByChar('custom\n'); |
65 | 221 |
|
66 | | - const changeFiles = getChangeFiles(repo.rootPath); |
67 | | - for (const file of changeFiles) { |
68 | | - const contents = await fs.readJSON(file); |
69 | | - expect(contents.changes).toHaveLength(2); |
70 | | - } |
| 222 | + // use defaults for second package |
| 223 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-2'); |
| 224 | + await stdin.sendByChar('\n\n'); |
71 | 225 |
|
| 226 | + await changePromise; |
| 227 | + |
| 228 | + expect(logs.mocks.log).toHaveBeenLastCalledWith(expect.stringMatching(/^git committed these change files:/)); |
| 229 | + expect(repo.status()).toBe(''); |
| 230 | + |
| 231 | + const changeFiles = getChangeFiles(repo.rootPath); |
72 | 232 | expect(changeFiles).toHaveLength(1); |
| 233 | + const contents = fs.readJSONSync(changeFiles[0]); |
| 234 | + expect(contents.changes).toEqual([ |
| 235 | + expect.objectContaining({ comment: 'custom', packageName: 'pkg-1', type: 'minor' }), |
| 236 | + expect.objectContaining({ comment: 'commit 2', packageName: 'pkg-2', type: 'patch' }), |
| 237 | + ]); |
73 | 238 | }); |
74 | 239 |
|
75 | | - it('create change file and commit', async () => { |
76 | | - repositoryFactory = new RepositoryFactory('single'); |
| 240 | + it('uses custom per-package prompt', async () => { |
| 241 | + repositoryFactory = new RepositoryFactory({ folders: monorepo }); |
77 | 242 | const repo = repositoryFactory.cloneRepository(); |
| 243 | + makeMonorepoChanges(repo); |
78 | 244 |
|
79 | | - await change({ |
80 | | - type: 'minor', |
81 | | - dependentChangeType: 'patch', |
82 | | - package: repositoryFactory.fixture.rootPackage!.name, |
83 | | - message: 'commit me please', |
| 245 | + mockBeachballOptions = { |
| 246 | + changeFilePrompt: { |
| 247 | + changePrompt: (defaultPrompt, pkg) => { |
| 248 | + const questions = [defaultPrompt.changeType!, defaultPrompt.description!]; |
| 249 | + return pkg === 'pkg-1' |
| 250 | + ? questions |
| 251 | + : [{ type: 'text', name: 'custom', message: 'custom question' }, ...questions]; |
| 252 | + }, |
| 253 | + }, |
| 254 | + }; |
| 255 | + |
| 256 | + const changePromise = change({ |
84 | 257 | path: repo.rootPath, |
85 | 258 | branch: defaultBranchName, |
| 259 | + groupChanges: true, |
86 | 260 | } as BeachballOptions); |
| 261 | + await waitForPrompt(); |
87 | 262 |
|
88 | | - expect(repo.status()).toBe(''); |
| 263 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-1'); |
| 264 | + expect(stdout.lastOutput()).toMatch(/Change type/); |
| 265 | + await stdin.sendByChar('\n'); |
| 266 | + expect(stdout.lastOutput()).toMatch(/Describe changes/); |
| 267 | + await stdin.sendByChar('\n'); |
| 268 | + |
| 269 | + expect(logs.mocks.log).toHaveBeenLastCalledWith('Please describe the changes for: pkg-2'); |
| 270 | + expect(stdout.lastOutput()).toMatch(/custom question/); |
| 271 | + await stdin.sendByChar('stuff\n'); |
| 272 | + expect(stdout.lastOutput()).toMatch(/Change type/); |
| 273 | + await stdin.sendByChar('\n'); |
| 274 | + expect(stdout.lastOutput()).toMatch(/Describe changes/); |
| 275 | + await stdin.sendByChar('\n'); |
| 276 | + |
| 277 | + await changePromise; |
89 | 278 |
|
90 | 279 | const changeFiles = getChangeFiles(repo.rootPath); |
91 | 280 | expect(changeFiles).toHaveLength(1); |
| 281 | + const contents = fs.readJSONSync(changeFiles[0]); |
| 282 | + expect(contents.changes).toEqual([ |
| 283 | + expect.objectContaining({ packageName: 'pkg-1', type: 'patch', comment: 'commit 2' }), |
| 284 | + expect.objectContaining({ packageName: 'pkg-2', type: 'patch', comment: 'commit 2', custom: 'stuff' }), |
| 285 | + ]); |
92 | 286 | }); |
| 287 | + |
| 288 | + // custom prompt for different packages (only truly doable here because elsewhere it uses combinedOptions) |
93 | 289 | }); |
0 commit comments