16.3 KB449 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
9import App from '../App';
10import {CommitInfoTestUtils, ignoreRTL} from '../testQueries';
11import {
12 closeCommitInfoSidebar,
13 COMMIT,
14 commitInfoIsOpen,
15 expectMessageSentToServer,
16 openCommitInfoSidebar,
17 resetTestMessages,
18 simulateCommits,
19 simulateRepoConnected,
20 simulateUncommittedChangedFiles,
21} from '../testUtils';
22import {CommandRunner} from '../types';
23
24describe('CommitTreeList', () => {
25 beforeEach(() => {
26 resetTestMessages();
27 });
28
29 it('shows loading spinner on mount', () => {
30 render(<App />);
31
32 expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
33 });
34
35 it('shows bug button even on error', () => {
36 render(<App />);
37
38 act(() => {
39 simulateCommits({
40 error: new Error('invalid certificate'),
41 });
42 });
43
44 expect(screen.getByTestId('bug-button')).toBeInTheDocument();
45 expect(screen.getByText('invalid certificate')).toBeInTheDocument();
46 });
47
48 describe('after commits loaded', () => {
49 beforeEach(() => {
50 render(<App />);
51 act(() => {
52 simulateRepoConnected();
53 closeCommitInfoSidebar();
54 expectMessageSentToServer({
55 type: 'subscribe',
56 kind: 'smartlogCommits',
57 subscriptionID: expect.anything(),
58 });
59 simulateCommits({
60 value: [
61 COMMIT('1', 'some public base', '0', {phase: 'public'}),
62 COMMIT('a', 'My Commit', '1'),
63 COMMIT('b', 'Another Commit', 'a', {isDot: true}),
64 ],
65 });
66 });
67 });
68
69 it('renders commits', () => {
70 expect(screen.getByText('My Commit')).toBeInTheDocument();
71 expect(screen.getByText('Another Commit')).toBeInTheDocument();
72 expect(screen.queryByText('some public base')).not.toBeInTheDocument();
73 });
74
75 it('renders exactly one head', () => {
76 expect(screen.getByText('You are here')).toBeInTheDocument();
77 });
78
79 describe('uncommitted changes', () => {
80 beforeEach(() => {
81 act(() => {
82 expectMessageSentToServer({
83 type: 'subscribe',
84 kind: 'uncommittedChanges',
85 subscriptionID: expect.anything(),
86 });
87 simulateUncommittedChangedFiles({
88 value: [
89 {path: 'src/file.js', status: 'M'},
90 {path: 'src/file_add.js', status: 'A'},
91 {path: 'src/file_removed.js', status: 'R'},
92 {path: 'src/file_untracked.js', status: '?'},
93 {path: 'src/file_missing.js', status: '!'},
94 ],
95 });
96 });
97 });
98
99 it('renders uncommitted changes', () => {
100 expect(screen.getByText(ignoreRTL('file.js'), {exact: false})).toBeInTheDocument();
101 expect(screen.getByText(ignoreRTL('file_add.js'), {exact: false})).toBeInTheDocument();
102 expect(screen.getByText(ignoreRTL('file_removed.js'), {exact: false})).toBeInTheDocument();
103 expect(
104 screen.getByText(ignoreRTL('file_untracked.js'), {exact: false}),
105 ).toBeInTheDocument();
106 expect(screen.getByText(ignoreRTL('file_missing.js'), {exact: false})).toBeInTheDocument();
107 });
108
109 it('shows quick commit button', () => {
110 expect(screen.getByText('Commit')).toBeInTheDocument();
111 });
112 it('shows quick amend button only on non-public commits', () => {
113 expect(screen.getByText('Amend')).toBeInTheDocument();
114 // checkout a public commit
115 act(() => {
116 simulateCommits({
117 value: [
118 COMMIT('1', 'some public base', '0', {phase: 'public', isDot: true}),
119 COMMIT('a', 'My Commit', '1', {successorInfo: {hash: 'a2', type: 'land'}}),
120 COMMIT('b', 'Another Commit', 'a'),
121 ],
122 });
123 });
124 // no longer see quick amend
125 expect(screen.queryByText('Amend')).not.toBeInTheDocument();
126 });
127
128 it('shows file actions', () => {
129 const fileActions = screen.getAllByTestId('file-actions');
130 expect(fileActions).toHaveLength(5); // 5 files
131 const revertButtons = screen.getAllByTestId('file-revert-button');
132 expect(revertButtons).toHaveLength(3); // modified, removed, missing files can be reverted
133 });
134
135 it('runs revert command when clicking revert button', async () => {
136 const revertButtons = screen.getAllByTestId('file-revert-button');
137 jest.spyOn(window, 'confirm').mockImplementation(() => true);
138 act(() => {
139 fireEvent.click(revertButtons[0]);
140 });
141 await waitFor(() => {
142 expect(window.confirm).toHaveBeenCalled();
143 expectMessageSentToServer({
144 type: 'runOperation',
145 operation: {
146 args: ['revert', {type: 'repo-relative-file-list', paths: ['src/file.js']}],
147 id: expect.anything(),
148 runner: CommandRunner.Sapling,
149 trackEventName: 'RevertOperation',
150 },
151 });
152 });
153 });
154
155 describe('addremove', () => {
156 it('hides addremove if all files tracked', () => {
157 act(() => {
158 simulateUncommittedChangedFiles({
159 value: [
160 {path: 'src/file.js', status: 'M'},
161 {path: 'src/file_add.js', status: 'A'},
162 {path: 'src/file_removed.js', status: 'R'},
163 ],
164 });
165 });
166 expect(screen.queryByTestId('addremove-button')).not.toBeInTheDocument();
167
168 act(() => {
169 simulateUncommittedChangedFiles({
170 value: [
171 {path: 'src/file.js', status: 'M'},
172 {path: 'src/file_add.js', status: 'A'},
173 {path: 'src/file_removed.js', status: 'R'},
174 {path: 'src/file_untracked.js', status: '?'},
175 {path: 'src/file_missing.js', status: '!'},
176 ],
177 });
178 });
179 expect(screen.queryByTestId('addremove-button')).toBeInTheDocument();
180 });
181
182 it('runs addremove', async () => {
183 const addremove = screen.getByTestId('addremove-button');
184 act(() => {
185 fireEvent.click(addremove);
186 });
187 await waitFor(() => {
188 expectMessageSentToServer({
189 type: 'runOperation',
190 operation: {
191 args: ['addremove'],
192 id: expect.anything(),
193 runner: CommandRunner.Sapling,
194 trackEventName: 'AddRemoveOperation',
195 },
196 });
197 });
198 });
199
200 it('optimistically updates file statuses while addremove is running', async () => {
201 const addremove = screen.getByTestId('addremove-button');
202 act(() => {
203 fireEvent.click(addremove);
204 });
205 await waitFor(() => {
206 expectMessageSentToServer({
207 type: 'runOperation',
208 operation: {
209 args: ['addremove'],
210 id: expect.anything(),
211 runner: CommandRunner.Sapling,
212 trackEventName: 'AddRemoveOperation',
213 },
214 });
215 });
216
217 expect(
218 document.querySelectorAll('.changed-files .changed-file.file-ignored'),
219 ).toHaveLength(0);
220 });
221
222 it('runs addremove only on selected files that are untracked', async () => {
223 const ignoredFileCheckboxes = document.querySelectorAll(
224 '.changed-files .changed-file.file-ignored input[type="checkbox"]',
225 );
226 const missingFileCheckboxes = document.querySelectorAll(
227 '.changed-files .changed-file.file-missing input[type="checkbox"]',
228 );
229 expect(ignoredFileCheckboxes).toHaveLength(1); // file_untracked.js
230 expect(missingFileCheckboxes).toHaveLength(1); // file_missing.js
231 act(() => {
232 fireEvent.click(missingFileCheckboxes[0]);
233 });
234
235 const addremove = screen.getByTestId('addremove-button');
236 act(() => {
237 fireEvent.click(addremove);
238 });
239 await waitFor(() => {
240 expectMessageSentToServer({
241 type: 'runOperation',
242 operation: {
243 // note: although src/file.js & others are selected, they aren't passed to addremove as they aren't untracked
244 args: ['addremove', {path: 'src/file_untracked.js', type: 'repo-relative-file'}],
245 id: expect.anything(),
246 runner: CommandRunner.Sapling,
247 trackEventName: 'AddRemoveOperation',
248 },
249 });
250 });
251 });
252 });
253 });
254
255 it('shows log errors', () => {
256 act(() => {
257 simulateCommits({
258 error: new Error('error running log'),
259 });
260 });
261 expect(screen.getByText('Failed to fetch commits')).toBeInTheDocument();
262 expect(screen.getByText('error running log')).toBeInTheDocument();
263
264 // we should still have commits from the last successful fetch
265 expect(screen.getByText('My Commit')).toBeInTheDocument();
266 expect(screen.getByText('Another Commit')).toBeInTheDocument();
267 expect(screen.queryByText('some public base')).not.toBeInTheDocument();
268 });
269
270 it('shows status errors', () => {
271 act(() => {
272 simulateUncommittedChangedFiles({
273 error: new Error('error running status'),
274 });
275 });
276 expect(screen.getByText('Failed to fetch Uncommitted Changes')).toBeInTheDocument();
277 expect(screen.getByText('error running status')).toBeInTheDocument();
278 });
279
280 it('shows successor info', () => {
281 act(() => {
282 simulateCommits({
283 value: [
284 COMMIT('1', 'some public base', '0', {phase: 'public'}),
285 COMMIT('a', 'My Commit', '1', {successorInfo: {hash: 'a2', type: 'land'}}),
286 COMMIT('b', 'Another Commit', 'a', {isDot: true}),
287 ],
288 });
289 });
290 expect(screen.getByText('Landed as a newer commit', {exact: false})).toBeInTheDocument();
291 });
292
293 it('shows button to open sidebar', () => {
294 act(() => {
295 simulateCommits({
296 value: [
297 COMMIT('1', 'some public base', '0', {phase: 'public'}),
298 COMMIT('a', 'Commit A', '1', {isDot: true}),
299 COMMIT('b', 'Commit B', '1'),
300 ],
301 });
302 });
303 expect(commitInfoIsOpen()).toBeFalsy();
304
305 // doesn't appear for public commits
306 expect(
307 within(screen.getByTestId('commit-1')).queryByTestId('open-commit-info-button'),
308 ).not.toBeInTheDocument();
309
310 const openButton = within(screen.getByTestId('commit-b')).getByTestId(
311 'open-commit-info-button',
312 );
313 expect(openButton).toBeInTheDocument();
314 // screen reader accessible
315 expect(screen.getByLabelText('Open commit "Commit B"')).toBeInTheDocument();
316 fireEvent.click(openButton);
317 expect(commitInfoIsOpen()).toBeTruthy();
318 expect(CommitInfoTestUtils.withinCommitInfo().getByText('Commit B')).toBeInTheDocument();
319 });
320
321 it('sets to amend mode when clicking open button', () => {
322 act(() => {
323 simulateCommits({
324 value: [
325 COMMIT('1', 'some public base', '0', {phase: 'public'}),
326 COMMIT('a', 'Commit A', '1', {isDot: true}),
327 COMMIT('b', 'Commit B', '1'),
328 ],
329 });
330 });
331 act(() => {
332 openCommitInfoSidebar();
333 });
334 CommitInfoTestUtils.clickCommitMode();
335
336 const openButton = within(screen.getByTestId('commit-b')).getByTestId(
337 'open-commit-info-button',
338 );
339 expect(openButton).toBeInTheDocument();
340 fireEvent.click(openButton);
341 expect(commitInfoIsOpen()).toBeTruthy();
342 expect(CommitInfoTestUtils.withinCommitInfo().getByText('Commit B')).toBeInTheDocument();
343 });
344 });
345
346 describe('render dag subset', () => {
347 describe('irrelevant cwd stacks', () => {
348 beforeEach(() => {
349 render(<App />);
350 act(() => {
351 // Set repo root to "/repo" and cwd to "/repo/www"
352 simulateRepoConnected('/repo', '/repo/www');
353 closeCommitInfoSidebar();
354 expectMessageSentToServer({
355 type: 'subscribe',
356 kind: 'smartlogCommits',
357 subscriptionID: expect.anything(),
358 });
359 simulateCommits({
360 value: [
361 COMMIT('1', 'Public base', '0', {phase: 'public'}),
362 // Commits with different maxCommonPathPrefix values
363 COMMIT('a', 'Commit in www', '1', {maxCommonPathPrefix: 'www/'}),
364 COMMIT('b', 'Commit in addons', '1', {maxCommonPathPrefix: 'addons/'}),
365 COMMIT('c', 'Commit in root', '1', {maxCommonPathPrefix: ''}),
366 COMMIT('d', 'Commit in www/js', '1', {
367 maxCommonPathPrefix: 'www/js/',
368 isDot: true,
369 }),
370 ],
371 });
372 });
373 });
374
375 it('shows irrelevant commits by default', () => {
376 // By default, hideIrrelevantCwdStacks is false, so all commits should be visible
377 expect(screen.queryByText('Commit in www')).toBeInTheDocument();
378 expect(screen.queryByText('Commit in addons')).toBeInTheDocument();
379 expect(screen.queryByText('Commit in root')).toBeInTheDocument();
380 expect(screen.queryByText('Commit in www/js')).toBeInTheDocument();
381
382 // But the addons/ commit should be marked as irrelevant
383 expect(document.body.querySelectorAll('.commit.irrelevant')).toHaveLength(1);
384 });
385
386 it('can hide irrelevant commits when enabled', async () => {
387 fireEvent.click(screen.getByTestId('settings-gear-button'));
388
389 const settingsDropdown = screen.getByTestId('settings-dropdown');
390 expect(settingsDropdown).toBeInTheDocument();
391 const cwdIrrelevantDropdown =
392 within(settingsDropdown).getByTestId('cwd-irrelevant-commits');
393 expect(cwdIrrelevantDropdown).toBeInTheDocument();
394
395 // Change the dropdown value to 'hide'
396 fireEvent.change(cwdIrrelevantDropdown, {target: {value: 'hide'}});
397
398 expect(screen.queryByText('Commit in www')).toBeInTheDocument();
399 expect(screen.queryByText('Commit in root')).toBeInTheDocument();
400 expect(screen.queryByText('Commit in www/js')).toBeInTheDocument();
401
402 expect(screen.queryByText('Commit in addons')).not.toBeInTheDocument();
403 });
404 });
405
406 describe('obsolete stacks', () => {
407 beforeEach(() => {
408 render(<App />);
409 act(() => {
410 simulateRepoConnected();
411 closeCommitInfoSidebar();
412 expectMessageSentToServer({
413 type: 'subscribe',
414 kind: 'smartlogCommits',
415 subscriptionID: expect.anything(),
416 });
417 simulateCommits({
418 value: [
419 COMMIT('1', 'some public base', '0', {phase: 'public'}),
420 COMMIT('a', 'Commit A', '1', {successorInfo: {hash: 'a2', type: 'rebase'}}),
421 COMMIT('b', 'Commit B', 'a', {successorInfo: {hash: 'b2', type: 'rebase'}}),
422 COMMIT('c', 'Commit C', 'b', {successorInfo: {hash: 'c2', type: 'rebase'}}),
423 COMMIT('d', 'Commit D', 'c', {successorInfo: {hash: 'd2', type: 'rebase'}}),
424 COMMIT('e', 'Commit E', 'd', {isDot: true}),
425 ],
426 });
427 });
428 });
429 it('hides obsolete stacks by default', () => {
430 expect(screen.queryByText('Commit A')).toBeInTheDocument();
431 expect(screen.queryByText('Commit B')).not.toBeInTheDocument();
432 expect(screen.queryByText('Commit C')).not.toBeInTheDocument();
433 expect(screen.queryByText('Commit D')).toBeInTheDocument();
434 expect(screen.queryByText('Commit E')).toBeInTheDocument();
435 });
436
437 it('can configure to not hide obsolete stacks', () => {
438 fireEvent.click(screen.getByTestId('settings-gear-button'));
439 fireEvent.click(screen.getByTestId('condense-obsolete-stacks'));
440 expect(screen.queryByText('Commit A')).toBeInTheDocument();
441 expect(screen.queryByText('Commit B')).toBeInTheDocument();
442 expect(screen.queryByText('Commit C')).toBeInTheDocument();
443 expect(screen.queryByText('Commit D')).toBeInTheDocument();
444 expect(screen.queryByText('Commit E')).toBeInTheDocument();
445 });
446 });
447 });
448});
449