Skip to content

Commit 35b8175

Browse files
authored
Add tests for change command (#788)
* increase debug timeout * Add tests for change command * split tests * fix test
1 parent 16fc40b commit 35b8175

14 files changed

Lines changed: 1539 additions & 186 deletions

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"runtimeExecutable": "npm",
1212
"cwd": "${workspaceFolder}",
1313
"runtimeArgs": ["run-script", "test"],
14-
"args": ["--", "--runInBand", "--watch", "${fileBasenameNoExtension}"],
14+
"args": ["--", "--runInBand", "--watch", "--testTimeout=1000000", "${fileBasenameNoExtension}"],
1515
"sourceMaps": true,
1616
"outputCapture": "std",
1717
"console": "integratedTerminal"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Refactor change command and promptForChange helper for better testability",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"jest": "28.1.3",
7474
"jest-mock": "28.1.3",
7575
"prettier": "2.7.1",
76+
"strip-ansi": "6.0.1",
7677
"tmp": "0.2.1",
7778
"ts-jest": "28.0.8",
7879
"typescript": "4.3.5",

src/__e2e__/change.test.ts

Lines changed: 237 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,289 @@
1-
import { describe, expect, it, afterEach } from '@jest/globals';
1+
import { describe, expect, it, afterEach, jest, beforeEach } from '@jest/globals';
22
import fs from 'fs-extra';
3+
import type prompts from 'prompts';
34
import { getChangeFiles } from '../__fixtures__/changeFiles';
45
import { initMockLogs } from '../__fixtures__/mockLogs';
5-
import { RepositoryFactory } from '../__fixtures__/repositoryFactory';
6+
import { RepoFixture, RepositoryFactory } from '../__fixtures__/repositoryFactory';
67
import { change } from '../commands/change';
78
import { BeachballOptions } from '../types/BeachballOptions';
89
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+
}
956

1057
describe('change command', () => {
1158
let repositoryFactory: RepositoryFactory | undefined;
1259

13-
initMockLogs();
60+
const logs = initMockLogs();
61+
62+
beforeEach(() => {
63+
stdin = new MockStdin();
64+
stdout = new MockStdout({ replace: 'prompts' });
65+
});
1466

1567
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+
});
20115
});
21116

22-
it('create change file but git stage only', async () => {
117+
it('creates and commits a change file', async () => {
23118
repositoryFactory = new RepositoryFactory('single');
24119
const repo = repositoryFactory.cloneRepository();
25120

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({
29148
package: repositoryFactory.fixture.rootPackage!.name,
30-
message: 'stage me please',
31149
path: repo.rootPath,
32150
branch: defaultBranchName,
33151
commit: false,
34152
} 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;
35159

36160
expect(repo.status()).toMatch(/^A change/);
37161

38162
const changeFiles = getChangeFiles(repo.rootPath);
39163
expect(changeFiles).toHaveLength(1);
40164
});
41165

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 });
51207
const repo = repositoryFactory.cloneRepository();
208+
makeMonorepoChanges(repo);
52209

53-
await change({
54-
type: 'minor',
55-
dependentChangeType: 'patch',
56-
package: ['pkg-1', 'pkg-2'],
57-
message: 'stage me please',
210+
const changePromise = change({
58211
path: repo.rootPath,
59212
branch: defaultBranchName,
60-
commit: false,
61213
groupChanges: true,
62214
} as BeachballOptions);
63215

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');
65221

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');
71225

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);
72232
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+
]);
73238
});
74239

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 });
77242
const repo = repositoryFactory.cloneRepository();
243+
makeMonorepoChanges(repo);
78244

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({
84257
path: repo.rootPath,
85258
branch: defaultBranchName,
259+
groupChanges: true,
86260
} as BeachballOptions);
261+
await waitForPrompt();
87262

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;
89278

90279
const changeFiles = getChangeFiles(repo.rootPath);
91280
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+
]);
92286
});
287+
288+
// custom prompt for different packages (only truly doable here because elsewhere it uses combinedOptions)
93289
});

0 commit comments

Comments
 (0)