49.6 KB1568 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 userEvent from '@testing-library/user-event';
10import App from '../App';
11import {tracker} from '../analytics';
12import platform from '../platform';
13import {CommitInfoTestUtils, CommitTreeListTestUtils, ignoreRTL} from '../testQueries';
14import {
15 COMMIT,
16 closeCommitInfoSidebar,
17 expectMessageSentToServer,
18 openCommitInfoSidebar,
19 resetTestMessages,
20 simulateCommits,
21 simulateMessageFromServer,
22 simulateUncommittedChangedFiles,
23 waitForWithTick,
24} from '../testUtils';
25import {CommandRunner, succeedableRevset} from '../types';
26
27/* eslint-disable @typescript-eslint/no-non-null-assertion */
28
29const {
30 withinCommitInfo,
31 clickAmendButton,
32 clickCancel,
33 clickCommitButton,
34 clickCommitMode,
35 clickToSelectCommit,
36 getTitleEditor,
37 getDescriptionEditor,
38 clickToEditTitle,
39 clickToEditDescription,
40 expectIsEditingTitle,
41 expectIsNOTEditingTitle,
42 expectIsEditingDescription,
43 expectIsNOTEditingDescription,
44} = CommitInfoTestUtils;
45
46describe('CommitInfoView', () => {
47 beforeEach(() => {
48 resetTestMessages();
49 jest.spyOn(tracker, 'track').mockImplementation(() => undefined);
50 });
51
52 it('shows loading spinner on mount', () => {
53 render(<App />);
54
55 expect(screen.getByTestId('commit-info-view-loading')).toBeInTheDocument();
56 });
57
58 describe('after commits loaded', () => {
59 beforeEach(() => {
60 render(<App />);
61 act(() => {
62 openCommitInfoSidebar();
63 expectMessageSentToServer({
64 type: 'subscribe',
65 kind: 'smartlogCommits',
66 subscriptionID: expect.anything(),
67 });
68 simulateCommits({
69 value: [
70 COMMIT('1', 'some public base', '0', {phase: 'public'}),
71 COMMIT('a', 'My Commit', '1'),
72 COMMIT('b', 'Head Commit', 'a', {isDot: true}),
73 ],
74 });
75 });
76 });
77
78 describe('drawer', () => {
79 it('can close commit info sidebar by clicking label', () => {
80 expect(screen.getByTestId('commit-info-view')).toBeInTheDocument();
81 expect(screen.getByText('Commit Info')).toBeInTheDocument();
82 act(() => {
83 closeCommitInfoSidebar();
84 });
85 expect(screen.queryByTestId('commit-info-view')).not.toBeInTheDocument();
86 expect(screen.getByText('Commit Info')).toBeInTheDocument();
87 });
88 });
89
90 describe('commit selection', () => {
91 it('shows head commit by default', () => {
92 expect(withinCommitInfo().queryByText('Head Commit')).toBeInTheDocument();
93 });
94
95 it('can click to select commit', () => {
96 clickToSelectCommit('a');
97
98 // now commit info view shows selected commit
99 expect(withinCommitInfo().queryByText('My Commit')).toBeInTheDocument();
100 expect(withinCommitInfo().queryByText('Head Commit')).not.toBeInTheDocument();
101 });
102
103 it('cannot select public commits', () => {
104 clickToSelectCommit('1');
105
106 expect(withinCommitInfo().queryByText('some public base')).not.toBeInTheDocument();
107 // stays on head commit
108 expect(withinCommitInfo().queryByText('Head Commit')).toBeInTheDocument();
109 });
110 });
111
112 describe('changed files', () => {
113 beforeEach(() => {
114 act(() => {
115 simulateCommits({
116 value: [
117 COMMIT('1', 'some public base', '0', {phase: 'public'}),
118 COMMIT('a', 'My Commit', '1', {filePathsSample: ['src/ca.js'], totalFileCount: 1}),
119 COMMIT('b', 'Head Commit', 'a', {
120 isDot: true,
121 filePathsSample: ['src/cb.js'],
122 totalFileCount: 1,
123 }),
124 ],
125 });
126 simulateUncommittedChangedFiles({
127 value: [
128 {path: 'src/file1.js', status: 'M'},
129 {path: 'src/file2.js', status: 'M'},
130 ],
131 });
132 });
133 });
134
135 it('shows uncommitted changes for head commit', () => {
136 expect(withinCommitInfo().queryByText(ignoreRTL('file1.js'))).toBeInTheDocument();
137 expect(withinCommitInfo().queryByText(ignoreRTL('file2.js'))).toBeInTheDocument();
138 });
139
140 it('shows file actions on uncommitted changes in commit info view', () => {
141 // (1) uncommitted changes in commit list, (2) uncommitted changes in commit info, (3) committed changes in commit info
142 expect(withinCommitInfo().queryAllByTestId('file-actions')).toHaveLength(3);
143 });
144
145 it("doesn't show uncommitted changes on non-head commits ", () => {
146 clickToSelectCommit('a');
147 expect(withinCommitInfo().queryByText(ignoreRTL('file1.js'))).not.toBeInTheDocument();
148 expect(withinCommitInfo().queryByText(ignoreRTL('file2.js'))).not.toBeInTheDocument();
149 });
150
151 it('shows files changed in the commit for head commit', () => {
152 expect(withinCommitInfo().queryByText(ignoreRTL('ca.js'))).not.toBeInTheDocument();
153 expect(withinCommitInfo().queryByText(ignoreRTL('cb.js'))).toBeInTheDocument();
154 });
155
156 it('shows files changed in the commit for non-head commit', () => {
157 clickToSelectCommit('a');
158 expect(withinCommitInfo().queryByText(ignoreRTL('ca.js'))).toBeInTheDocument();
159 expect(withinCommitInfo().queryByText(ignoreRTL('cb.js'))).not.toBeInTheDocument();
160 });
161
162 it('enables amend button with uncommitted changes', () => {
163 expect(withinCommitInfo().queryByText(ignoreRTL('file1.js'))).toBeInTheDocument();
164 expect(withinCommitInfo().queryByText(ignoreRTL('file2.js'))).toBeInTheDocument();
165
166 const amendButton: HTMLButtonElement | null = within(
167 screen.getByTestId('commit-info-actions-bar'),
168 ).queryByText('Amend');
169 expect(amendButton).toBeInTheDocument();
170 expect(amendButton?.disabled).not.toBe(true);
171 });
172
173 it('does not show banner if all files are shown', () => {
174 expect(
175 withinCommitInfo().queryByText(/Showing first .* files out of .* total/),
176 ).not.toBeInTheDocument();
177 });
178
179 it('shows banner if not all files are shown', () => {
180 act(() => {
181 simulateCommits({
182 value: [
183 COMMIT('1', 'some public base', '0', {phase: 'public'}),
184 COMMIT('a', 'Head Commit', '1', {
185 isDot: true,
186 filePathsSample: new Array(25).fill(null).map((_, i) => `src/file${i}.txt`),
187 totalFileCount: 100,
188 }),
189 ],
190 });
191 simulateUncommittedChangedFiles({
192 value: [],
193 });
194 });
195
196 expect(withinCommitInfo().queryByText(ignoreRTL('file1.txt'))).toBeInTheDocument();
197 expect(
198 withinCommitInfo().queryByText('Showing first 25 files out of 100 total'),
199 ).toBeInTheDocument();
200 });
201
202 it('runs amend with selected files', async () => {
203 expect(withinCommitInfo().queryByText(ignoreRTL('file1.js'))).toBeInTheDocument();
204 expect(withinCommitInfo().queryByText(ignoreRTL('file2.js'))).toBeInTheDocument();
205
206 act(() => {
207 const checkboxes = withinCommitInfo()
208 .queryByTestId('changes-to-amend')!
209 .querySelectorAll('input[type="checkbox"]');
210 fireEvent.click(checkboxes[0]);
211 });
212
213 const amendButton = within(screen.getByTestId('commit-info-actions-bar')).queryByText(
214 'Amend',
215 );
216 expect(amendButton).toBeInTheDocument();
217
218 act(() => {
219 fireEvent.click(amendButton!);
220 });
221
222 await waitFor(() =>
223 expectMessageSentToServer({
224 type: 'runOperation',
225 operation: {
226 args: [
227 {type: 'config', key: 'amend.autorestack', value: 'always'},
228 'amend',
229 '--addremove',
230 {type: 'repo-relative-file', path: 'src/file2.js'},
231 '--message',
232 expect.stringMatching(/^Head Commit/),
233 ],
234 id: expect.anything(),
235 runner: CommandRunner.Sapling,
236 trackEventName: 'AmendFileSubsetOperation',
237 },
238 }),
239 );
240 });
241
242 it('disallows amending when all uncommitted changes deselected', () => {
243 // click every checkbox in changes to amend
244 act(() => {
245 const checkboxes = withinCommitInfo()
246 .queryByTestId('changes-to-amend')
247 ?.querySelectorAll('input[type="checkbox"]');
248 checkboxes?.forEach(checkbox => {
249 fireEvent.click(checkbox);
250 });
251 });
252
253 const amendButton: HTMLButtonElement | null = within(
254 screen.getByTestId('commit-info-actions-bar'),
255 ).queryByText('Amend');
256 expect(amendButton).toBeInTheDocument();
257 expect(amendButton?.disabled).toBe(true);
258 });
259
260 it('shows optimistic uncommitted changes', async () => {
261 act(() => {
262 simulateUncommittedChangedFiles({
263 value: [],
264 });
265 });
266
267 expect(screen.queryByText('Amend and Submit')).not.toBeInTheDocument();
268
269 jest.spyOn(platform, 'confirm').mockImplementation(() => Promise.resolve(true));
270 act(() => {
271 fireEvent.click(screen.getByText('Uncommit'));
272 });
273 act(() => {
274 simulateMessageFromServer({
275 type: 'fetchedCommitChangedFiles',
276 hash: 'b',
277 result: {
278 value: {
279 totalFileCount: 3,
280 filesSample: [{path: 'src/cb.js', status: 'M'}],
281 },
282 },
283 });
284 });
285
286 await waitFor(() => {
287 expect(withinCommitInfo().queryByText(ignoreRTL('cb.js'))).toBeInTheDocument();
288 expect(screen.queryByText('Amend and Submit')).toBeInTheDocument();
289 });
290 });
291 });
292
293 describe('editing fields', () => {
294 beforeEach(() => {
295 act(() => {
296 simulateCommits({
297 value: [
298 COMMIT('1', 'some public base', '0', {phase: 'public'}),
299 COMMIT('a', 'My Commit', '1', {description: 'Summary: First commit in the stack'}),
300 COMMIT('b', 'Head Commit', 'a', {
301 description: 'Summary: stacked commit',
302 isDot: true,
303 }),
304 ],
305 });
306 });
307 });
308
309 it('starts editing title when clicked', () => {
310 expectIsNOTEditingTitle();
311 clickToEditTitle();
312 expectIsEditingTitle();
313 });
314
315 it('starts editing description when clicked', () => {
316 expectIsNOTEditingDescription();
317 clickToEditDescription();
318 expectIsEditingDescription();
319 });
320
321 it('cancel button stops editing', () => {
322 clickToEditTitle();
323 clickToEditDescription();
324 expectIsEditingTitle();
325 expectIsEditingDescription();
326
327 const cancelButton: HTMLButtonElement | null = withinCommitInfo().queryByText('Cancel');
328 expect(cancelButton).toBeInTheDocument();
329
330 act(() => {
331 fireEvent.click(cancelButton!);
332 });
333
334 expectIsNOTEditingTitle();
335 expectIsNOTEditingDescription();
336 });
337
338 it('amend button stops editing', async () => {
339 act(() =>
340 simulateUncommittedChangedFiles({
341 value: [{path: 'src/file1.js', status: 'M'}],
342 }),
343 );
344
345 clickToEditTitle();
346 clickToEditDescription();
347 expectIsEditingTitle();
348 expectIsEditingDescription();
349
350 const amendButton: HTMLButtonElement | null = within(
351 screen.getByTestId('commit-info-actions-bar'),
352 ).queryByText('Amend');
353 expect(amendButton).toBeInTheDocument();
354 expect(amendButton?.disabled).not.toEqual(true);
355
356 act(() => {
357 fireEvent.click(amendButton!);
358 });
359
360 await waitFor(() =>
361 expectMessageSentToServer({
362 type: 'runOperation',
363 operation: expect.objectContaining({
364 args: expect.arrayContaining(['amend']),
365 }),
366 }),
367 );
368
369 expectIsNOTEditingTitle();
370 expectIsNOTEditingDescription();
371 });
372
373 it('resets edited fields when changing selected commit', () => {
374 clickToEditTitle();
375 clickToEditDescription();
376 expectIsEditingTitle();
377 expectIsEditingDescription();
378
379 clickToSelectCommit('a');
380
381 expectIsNOTEditingTitle();
382 expectIsNOTEditingDescription();
383 });
384
385 it('fields stay reset after switching commit, if there were no real changes made', () => {
386 clickToEditTitle();
387 clickToEditDescription();
388 expectIsEditingTitle();
389 expectIsEditingDescription();
390
391 clickToSelectCommit('a');
392
393 expectIsNOTEditingTitle();
394 expectIsNOTEditingDescription();
395
396 clickToSelectCommit('b');
397
398 expectIsNOTEditingTitle();
399 expectIsNOTEditingDescription();
400 });
401
402 it('edited fields go back into editing state when returning to selected commit', () => {
403 clickToEditTitle();
404 clickToEditDescription();
405 expectIsEditingTitle();
406 expectIsEditingDescription();
407
408 {
409 act(() => {
410 userEvent.type(getTitleEditor(), ' hello new title');
411 userEvent.type(getDescriptionEditor(), '\nhello new text');
412 });
413 }
414
415 clickToSelectCommit('a');
416
417 expectIsNOTEditingTitle();
418 expectIsNOTEditingDescription();
419
420 clickToSelectCommit('b');
421
422 expectIsEditingTitle();
423 expectIsEditingDescription();
424
425 {
426 expect(getTitleEditor().value).toBe('Head Commit hello new title');
427 expect(getDescriptionEditor().value).toEqual(
428 expect.stringContaining('stacked commit\nhello new text'),
429 );
430 }
431 });
432
433 it('cannot type newlines into title', () => {
434 clickToEditTitle();
435 expectIsEditingTitle();
436
437 act(() => {
438 userEvent.type(getTitleEditor(), ' hello\nsomething\r\nhi');
439 });
440 expect(getTitleEditor().value).toBe('Head Commit hellosomethinghi');
441 });
442
443 describe('focus', () => {
444 it('focuses title when you start editing', async () => {
445 clickToEditTitle();
446
447 await waitFor(() => {
448 expect(getTitleEditor()).toHaveFocus();
449 });
450 });
451
452 it('focuses summary when you start editing', async () => {
453 clickToEditDescription();
454
455 await waitFor(() => {
456 expect(getDescriptionEditor()).toHaveFocus();
457 });
458 });
459
460 it('focuses topmost field when all fields start being edited', async () => {
461 act(() => {
462 simulateUncommittedChangedFiles({value: [{path: 'src/file1.js', status: 'M'}]});
463 });
464 // edit fields, then switch selected commit and switch back to edit both fields together
465 fireEvent.click(screen.getByText('Amend as...', {exact: false}));
466
467 await waitFor(() => {
468 expect(getTitleEditor()).toHaveFocus();
469 expect(getDescriptionEditor()).not.toHaveFocus();
470 });
471 });
472
473 it('focuses topmost edited field when loading from saved state', async () => {
474 act(() => {
475 clickToEditTitle();
476 clickToEditDescription();
477 });
478 {
479 act(() => {
480 userEvent.type(getTitleEditor(), ' hello new title');
481 userEvent.type(getDescriptionEditor(), '\nhello new text');
482 });
483 }
484
485 clickToSelectCommit('a');
486 clickToSelectCommit('b');
487
488 expectIsEditingTitle();
489 expectIsEditingDescription();
490
491 await waitFor(() => {
492 expect(getTitleEditor()).toHaveFocus();
493 expect(getDescriptionEditor()).not.toHaveFocus();
494 });
495 });
496 });
497
498 describe('head commit', () => {
499 it('only has metaedit button on non-head commits', () => {
500 {
501 const preChangeAmendMessageButton = within(
502 screen.getByTestId('commit-info-actions-bar'),
503 ).queryByText('Amend Message');
504 expect(preChangeAmendMessageButton).not.toBeInTheDocument();
505 }
506
507 clickToSelectCommit('a');
508
509 const amendMessageButton = within(
510 screen.getByTestId('commit-info-actions-bar'),
511 ).queryByText('Amend Message');
512 expect(amendMessageButton).toBeInTheDocument();
513 });
514
515 it('has "You are here" on head commit', () => {
516 expect(withinCommitInfo().queryByText('You are here')).toBeInTheDocument();
517 });
518
519 it('does not have "You are here" on non-head commit', () => {
520 clickToSelectCommit('a');
521 expect(withinCommitInfo().queryByText('You are here')).not.toBeInTheDocument();
522 });
523
524 it('does not have "You are here" in commit mode', () => {
525 clickCommitMode();
526 expect(withinCommitInfo().queryByText('You are here')).not.toBeInTheDocument();
527 });
528 });
529
530 describe('running commands', () => {
531 describe('metaedit', () => {
532 it('disables metaedit button if no fields edited', () => {
533 clickToSelectCommit('a');
534
535 const amendMessageButton = within(
536 screen.getByTestId('commit-info-actions-bar'),
537 ).queryByText('Amend Message') as HTMLButtonElement;
538 expect(amendMessageButton).toBeInTheDocument();
539 expect(amendMessageButton!.disabled).toBe(true);
540 });
541
542 it('enables metaedit button if fields are edited', () => {
543 clickToSelectCommit('a');
544
545 act(() => {
546 clickToEditTitle();
547 clickToEditDescription();
548 });
549 });
550
551 it('runs metaedit', async () => {
552 clickToSelectCommit('a');
553
554 act(() => {
555 clickToEditTitle();
556 clickToEditDescription();
557 });
558
559 {
560 act(() => {
561 userEvent.type(getTitleEditor(), ' hello new title');
562 userEvent.type(getDescriptionEditor(), '\nhello new text');
563 });
564 }
565
566 const amendMessageButton = within(
567 screen.getByTestId('commit-info-actions-bar'),
568 ).queryByText('Amend Message');
569 act(() => {
570 fireEvent.click(amendMessageButton!);
571 });
572 await waitFor(() =>
573 expectMessageSentToServer({
574 type: 'runOperation',
575 operation: {
576 args: [
577 'metaedit',
578 '--rev',
579 succeedableRevset('a'),
580 '--message',
581 expect.stringMatching(
582 /^My Commit hello new title\n+(Summary:\s+)?First commit in the stack\nhello new text/,
583 ),
584 ],
585 id: expect.anything(),
586 runner: CommandRunner.Sapling,
587 trackEventName: 'AmendMessageOperation',
588 },
589 }),
590 );
591 });
592
593 it('disables metaedit button with spinner while running', async () => {
594 clickToSelectCommit('a');
595
596 act(() => {
597 clickToEditTitle();
598 clickToEditDescription();
599 });
600 {
601 act(() => {
602 userEvent.type(getTitleEditor(), ' hello new title');
603 userEvent.type(getDescriptionEditor(), '\nhello new text');
604 });
605 }
606
607 const amendMessageButton = within(
608 screen.getByTestId('commit-info-actions-bar'),
609 ).queryByText('Amend Message');
610 act(() => {
611 fireEvent.click(amendMessageButton!);
612 });
613
614 await waitForWithTick(() => {
615 expect(amendMessageButton).toBeDisabled();
616 });
617 });
618 });
619
620 describe('amend', () => {
621 it('runs amend with changed message', async () => {
622 act(() =>
623 simulateUncommittedChangedFiles({
624 value: [{path: 'src/file1.js', status: 'M'}],
625 }),
626 );
627
628 act(() => {
629 clickToEditTitle();
630 clickToEditDescription();
631 });
632
633 {
634 act(() => {
635 userEvent.type(getTitleEditor(), ' hello new title');
636 userEvent.type(getDescriptionEditor(), '\nhello new text');
637 });
638 }
639
640 clickAmendButton();
641
642 await waitFor(() =>
643 expectMessageSentToServer({
644 type: 'runOperation',
645 operation: {
646 args: [
647 {type: 'config', key: 'amend.autorestack', value: 'always'},
648 'amend',
649 '--addremove',
650 '--message',
651 expect.stringMatching(
652 /^Head Commit hello new title\n+(Summary:\s+)?stacked commit\nhello new text/,
653 ),
654 ],
655 id: expect.anything(),
656 runner: CommandRunner.Sapling,
657 trackEventName: 'AmendOperation',
658 },
659 }),
660 );
661 });
662
663 it('deselects head when running amend', async () => {
664 act(() =>
665 simulateUncommittedChangedFiles({
666 value: [{path: 'src/file1.js', status: 'M'}],
667 }),
668 );
669
670 // even though 'b' is already shown when nothing is selected,
671 // we want to use auto-selection after amending even if you previously selected something
672 clickToSelectCommit('b');
673
674 clickToEditTitle();
675
676 {
677 act(() => {
678 userEvent.type(getTitleEditor(), ' hello');
679 });
680 }
681
682 clickAmendButton();
683
684 // no commit is selected anymore
685 await waitFor(() =>
686 expect(screen.queryByTestId('selected-commit')).not.toBeInTheDocument(),
687 );
688 });
689
690 it('does not deselect non-head commits after running amend', () => {
691 act(() => {
692 simulateUncommittedChangedFiles({
693 value: [{path: 'src/file1.js', status: 'M'}],
694 });
695 });
696
697 clickToSelectCommit('a');
698
699 // we can't use amend button in the commit info because we're on some other commit, so use quick amend
700 const quickAmendButton = screen.getByTestId('uncommitted-changes-quick-amend-button');
701 act(() => {
702 fireEvent.click(quickAmendButton);
703 });
704
705 // commit remains selected
706 expect(
707 within(screen.getByTestId('commit-a')).queryByTestId('selected-commit'),
708 ).toBeInTheDocument();
709 });
710
711 it('disables amend button with spinner while running', async () => {
712 act(() => {
713 simulateUncommittedChangedFiles({
714 value: [{path: 'src/file1.js', status: 'M'}],
715 });
716 });
717
718 clickAmendButton();
719 await waitFor(() =>
720 expectMessageSentToServer({
721 type: 'runOperation',
722 operation: expect.objectContaining({
723 args: expect.arrayContaining(['amend']),
724 }),
725 }),
726 );
727
728 act(() => {
729 simulateUncommittedChangedFiles({
730 value: [{path: 'src/file2.js', status: 'M'}],
731 });
732 });
733
734 const amendMessageButton = within(
735 screen.getByTestId('commit-info-actions-bar'),
736 ).queryByText('Amend');
737 act(() => {
738 fireEvent.click(amendMessageButton!);
739 });
740
741 expect(amendMessageButton).toBeDisabled();
742 });
743
744 it('shows amend message instead of amend when there are only message changes', () => {
745 act(() => {
746 simulateUncommittedChangedFiles({
747 value: [{path: 'src/file1.js', status: 'M'}],
748 });
749 });
750
751 expect(
752 within(screen.getByTestId('commit-info-actions-bar')).queryByText('Amend'),
753 ).toBeInTheDocument();
754
755 act(() => {
756 simulateUncommittedChangedFiles({
757 value: [],
758 });
759 });
760
761 expect(
762 within(screen.getByTestId('commit-info-actions-bar')).queryByText('Amend'),
763 ).toBeInTheDocument();
764
765 act(() => {
766 clickToEditTitle();
767 clickToEditDescription();
768 });
769
770 // no uncommitted changes, and message is being changed
771 expect(
772 within(screen.getByTestId('commit-info-actions-bar')).queryByText('Amend Message'),
773 ).toBeInTheDocument();
774 });
775 });
776 });
777
778 describe('commit mode', () => {
779 it('has commit mode selector on head commit', () => {
780 expect(
781 within(screen.getByTestId('commit-info-toolbar-top')).getByText('Amend'),
782 ).toBeInTheDocument();
783 expect(
784 within(screen.getByTestId('commit-info-toolbar-top')).getByText('Commit'),
785 ).toBeInTheDocument();
786 });
787
788 it('does not have commit mode selector on non-head commits', () => {
789 clickToSelectCommit('a');
790 // toolbar won't appear at all on non-head commits right now
791 expect(screen.queryByTestId('commit-info-toolbar-top')).not.toBeInTheDocument();
792 });
793
794 it('clicking commit mode starts editing both fields', () => {
795 clickCommitMode();
796
797 expectIsEditingTitle();
798 expectIsEditingDescription();
799 });
800
801 it('commit mode message is saved separately', () => {
802 clickCommitMode();
803
804 expectIsEditingTitle();
805 expectIsEditingDescription();
806
807 act(() => {
808 userEvent.type(getTitleEditor(), 'new commit title');
809 userEvent.type(getDescriptionEditor(), 'my description');
810 });
811
812 clickToSelectCommit('a');
813 clickToSelectCommit('b');
814
815 expectIsEditingTitle();
816 expectIsEditingDescription();
817
818 expect(getTitleEditor().value).toBe('new commit title');
819 expect(getDescriptionEditor().value).toBe('my description');
820 });
821
822 it('focuses title when opening commit mode', async () => {
823 clickCommitMode();
824
825 await waitFor(() => {
826 expect(getTitleEditor()).toHaveFocus();
827 expect(getDescriptionEditor()).not.toHaveFocus();
828 });
829 });
830
831 it("disables commit button if there's no changed files", () => {
832 clickCommitMode();
833
834 const commitButton = within(screen.getByTestId('commit-info-actions-bar')).queryByText(
835 'Commit',
836 ) as HTMLButtonElement;
837 expect(commitButton).toBeInTheDocument();
838 expect(commitButton!.disabled).toBe(true);
839 });
840
841 it('does not disable commit button if there are changed files', () => {
842 act(() => {
843 simulateUncommittedChangedFiles({
844 value: [
845 {path: 'src/file1.js', status: 'M'},
846 {path: 'src/file2.js', status: 'M'},
847 ],
848 });
849 });
850
851 clickCommitMode();
852
853 const commitButton = within(screen.getByTestId('commit-info-actions-bar')).queryByText(
854 'Commit',
855 ) as HTMLButtonElement;
856 expect(commitButton).toBeInTheDocument();
857 expect(commitButton!.disabled).not.toBe(true);
858 });
859
860 it('runs commit with message', async () => {
861 act(() => {
862 simulateUncommittedChangedFiles({
863 value: [
864 {path: 'src/file1.js', status: 'M'},
865 {path: 'src/file2.js', status: 'M'},
866 ],
867 });
868 });
869
870 clickCommitMode();
871
872 act(() => {
873 userEvent.type(getTitleEditor(), 'new commit title');
874 userEvent.type(getDescriptionEditor(), 'my description');
875 });
876
877 clickCommitButton();
878
879 await waitFor(() =>
880 expectMessageSentToServer({
881 type: 'runOperation',
882 operation: {
883 args: [
884 'commit',
885 '--addremove',
886 '--message',
887 expect.stringMatching(/^new commit title\n+(Summary:\s+)?my description/),
888 ],
889 id: expect.anything(),
890 runner: CommandRunner.Sapling,
891 trackEventName: 'CommitOperation',
892 },
893 }),
894 );
895 });
896
897 it('resets to amend mode after committing', async () => {
898 act(() => {
899 simulateUncommittedChangedFiles({
900 value: [
901 {path: 'src/file1.js', status: 'M'},
902 {path: 'src/file2.js', status: 'M'},
903 ],
904 });
905 });
906
907 clickCommitMode();
908
909 act(() => {
910 userEvent.type(getTitleEditor(), 'new commit title');
911 userEvent.type(getDescriptionEditor(), 'my description');
912 });
913
914 clickCommitButton();
915
916 await waitFor(() =>
917 expectMessageSentToServer({
918 type: 'runOperation',
919 operation: expect.objectContaining({
920 args: expect.arrayContaining(['commit']),
921 }),
922 }),
923 );
924
925 const commitButtonAfter = within(
926 screen.getByTestId('commit-info-actions-bar'),
927 ).queryByText('Commit');
928 expect(commitButtonAfter).not.toBeInTheDocument();
929 });
930
931 it('does not have cancel button', () => {
932 clickCommitMode();
933
934 const cancelButton: HTMLButtonElement | null = withinCommitInfo().queryByText('Cancel');
935 expect(cancelButton).not.toBeInTheDocument();
936 });
937 });
938
939 describe('edited messages indicator', () => {
940 it('does not show edited message indicator when fields are not actually changed', () => {
941 act(() => {
942 clickToEditTitle();
943 clickToEditDescription();
944 });
945 expectIsEditingTitle();
946 expectIsEditingDescription();
947
948 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
949
950 {
951 act(() => {
952 // type something and delete it
953 userEvent.type(getTitleEditor(), 'Q{Backspace}');
954 userEvent.type(getDescriptionEditor(), 'Q{Backspace}');
955 });
956 }
957
958 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
959 });
960
961 it('shows edited message indicator when title changed', () => {
962 act(() => {
963 clickToEditTitle();
964 clickToEditDescription();
965 });
966
967 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
968
969 {
970 act(() => {
971 userEvent.type(getTitleEditor(), 'Q');
972 });
973 }
974
975 expect(screen.queryByTestId('unsaved-message-indicator')).toBeInTheDocument();
976 expect(
977 within(screen.queryByTestId('commit-b')!).queryByTestId('unsaved-message-indicator'),
978 ).toBeInTheDocument();
979 });
980
981 it('shows edited message indicator when description changed', () => {
982 act(() => {
983 clickToEditTitle();
984 clickToEditDescription();
985 });
986
987 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
988
989 {
990 act(() => {
991 userEvent.type(getDescriptionEditor(), 'Q');
992 });
993 }
994
995 expect(screen.queryByTestId('unsaved-message-indicator')).toBeInTheDocument();
996 expect(
997 within(screen.queryByTestId('commit-b')!).queryByTestId('unsaved-message-indicator'),
998 ).toBeInTheDocument();
999 });
1000
1001 it('appears for other commits', () => {
1002 clickToSelectCommit('a');
1003
1004 act(() => {
1005 clickToEditTitle();
1006 clickToEditDescription();
1007 });
1008
1009 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
1010
1011 {
1012 act(() => {
1013 userEvent.type(getTitleEditor(), 'Q');
1014 userEvent.type(getDescriptionEditor(), 'Q');
1015 });
1016 }
1017
1018 expect(screen.queryByTestId('unsaved-message-indicator')).toBeInTheDocument();
1019 expect(
1020 within(screen.queryByTestId('commit-a')!).queryByTestId('unsaved-message-indicator'),
1021 ).toBeInTheDocument();
1022 });
1023
1024 it('commit mode does not cause indicator', () => {
1025 clickCommitMode();
1026
1027 {
1028 act(() => {
1029 userEvent.type(getTitleEditor(), 'Q');
1030 userEvent.type(getDescriptionEditor(), 'Q');
1031 });
1032 }
1033 expect(screen.queryByTestId('unsaved-message-indicator')).not.toBeInTheDocument();
1034 });
1035 });
1036
1037 describe('commit message template', () => {
1038 it('requests commit template when opening commit form', () => {
1039 clickCommitMode();
1040 expectMessageSentToServer({type: 'fetchCommitMessageTemplate'});
1041 });
1042
1043 it('loads template sent by server', () => {
1044 clickCommitMode();
1045 act(() => {
1046 simulateMessageFromServer({
1047 type: 'fetchedCommitMessageTemplate',
1048 template: '[isl]\nSummary: Hello\nTest Plan:\n',
1049 });
1050 });
1051
1052 expect(getTitleEditor().value).toBe('[isl]');
1053 expect(getDescriptionEditor().value).toEqual(expect.stringMatching(/(Summary: )?Hello/));
1054 });
1055
1056 it('only asynchronously overwrites default commit fields', () => {
1057 clickCommitMode();
1058
1059 // type something in fields...
1060 {
1061 act(() => {
1062 userEvent.type(getTitleEditor(), 'Q');
1063 userEvent.type(getDescriptionEditor(), 'W');
1064 });
1065 }
1066
1067 // template arrives from server later
1068 act(() => {
1069 simulateMessageFromServer({
1070 type: 'fetchedCommitMessageTemplate',
1071 template: '[isl]\nSummary:\nTest Plan:\n',
1072 });
1073 });
1074
1075 // template shouldn't have overwritten fields since they're non-default now
1076 expect(getTitleEditor().value).toBe('Q');
1077 expect(getDescriptionEditor().value).toBe('W');
1078 });
1079 });
1080
1081 describe('discarding message', () => {
1082 it('confirms cancel button if you have made changes to the title', async () => {
1083 clickToEditTitle();
1084 const confirmSpy = jest
1085 .spyOn(platform, 'confirm')
1086 .mockImplementation(() => Promise.resolve(true));
1087
1088 act(() => {
1089 userEvent.type(getTitleEditor(), 'Q');
1090 });
1091
1092 clickCancel();
1093
1094 await waitFor(() => {
1095 expectIsNOTEditingTitle();
1096 expectIsNOTEditingDescription();
1097 });
1098 expect(confirmSpy).toHaveBeenCalled();
1099 });
1100
1101 it('confirms cancel button if you have made changes to the description', async () => {
1102 clickToEditDescription();
1103 const confirmSpy = jest
1104 .spyOn(platform, 'confirm')
1105 .mockImplementation(() => Promise.resolve(true));
1106
1107 act(() => {
1108 userEvent.type(getDescriptionEditor(), 'W');
1109 });
1110
1111 clickCancel();
1112
1113 await waitFor(() => {
1114 expectIsNOTEditingTitle();
1115 expectIsNOTEditingDescription();
1116 });
1117 expect(confirmSpy).toHaveBeenCalled();
1118 });
1119
1120 it('does not cancel if you do not confirm', async () => {
1121 act(() => {
1122 clickToEditTitle();
1123 clickToEditDescription();
1124 });
1125 const confirmSpy = jest
1126 .spyOn(platform, 'confirm')
1127 .mockImplementation(() => Promise.resolve(false));
1128
1129 act(() => {
1130 userEvent.type(getTitleEditor(), 'Q');
1131 userEvent.type(getDescriptionEditor(), 'W');
1132 });
1133
1134 clickCancel();
1135
1136 await waitFor(() => {
1137 expectIsEditingTitle();
1138 expectIsEditingDescription();
1139
1140 expect(getTitleEditor().value).toBe('Head CommitQ');
1141 expect(getDescriptionEditor().value).toEqual(
1142 expect.stringContaining('stacked commitW'),
1143 );
1144 });
1145 expect(confirmSpy).toHaveBeenCalled();
1146 });
1147
1148 it('does not confirm when clearing for amend', async () => {
1149 act(() =>
1150 simulateUncommittedChangedFiles({
1151 value: [{path: 'src/file1.js', status: 'M'}],
1152 }),
1153 );
1154
1155 clickToEditDescription();
1156 const confirmSpy = jest.spyOn(platform, 'confirm');
1157
1158 act(() => {
1159 userEvent.type(getDescriptionEditor(), 'W');
1160 });
1161
1162 clickAmendButton();
1163
1164 await waitForWithTick(() => {
1165 expectIsNOTEditingTitle();
1166 expectIsNOTEditingDescription();
1167 expect(confirmSpy).not.toHaveBeenCalled();
1168 });
1169 });
1170 });
1171
1172 describe('optimistic state', () => {
1173 it('takes previews into account when rendering head', async () => {
1174 await CommitTreeListTestUtils.clickGoto('a');
1175 // while optimistic state happening...
1176 // show new commit in commit info without clicking it (because head is auto-selected)
1177 expect(withinCommitInfo().queryByText('My Commit')).toBeInTheDocument();
1178 expect(withinCommitInfo().queryByText('You are here')).toBeInTheDocument();
1179 });
1180
1181 it('shows new head when running goto', async () => {
1182 clickToSelectCommit('b'); // explicitly select
1183 await CommitTreeListTestUtils.clickGoto('a');
1184
1185 expect(withinCommitInfo().queryByText('My Commit')).toBeInTheDocument();
1186 expect(withinCommitInfo().queryByText('You are here')).toBeInTheDocument();
1187 });
1188
1189 it('renders metaedit operation smoothly', async () => {
1190 clickToSelectCommit('a');
1191
1192 act(() => {
1193 clickToEditTitle();
1194 clickToEditDescription();
1195 });
1196 act(() => {
1197 userEvent.type(getTitleEditor(), ' with change!');
1198 userEvent.type(getDescriptionEditor(), '\nmore stuff!');
1199 });
1200
1201 const amendMessageButton = within(
1202 screen.getByTestId('commit-info-actions-bar'),
1203 ).queryByText('Amend Message');
1204 act(() => {
1205 fireEvent.click(amendMessageButton!);
1206 });
1207
1208 await waitFor(() => {
1209 expectIsNOTEditingTitle();
1210 expectIsNOTEditingDescription();
1211
1212 expect(withinCommitInfo().getByText('My Commit with change!')).toBeInTheDocument();
1213 expect(
1214 withinCommitInfo().getByText(/First commit in the stack\nmore stuff!/, {
1215 collapseWhitespace: false,
1216 }),
1217 ).toBeInTheDocument();
1218 });
1219 });
1220
1221 it('renders commit operation smoothly', async () => {
1222 act(() => {
1223 simulateUncommittedChangedFiles({
1224 value: [{path: 'src/file1.js', status: 'M'}],
1225 });
1226 });
1227
1228 clickCommitMode();
1229 act(() => {
1230 userEvent.type(getTitleEditor(), 'New Commit');
1231 userEvent.type(getDescriptionEditor(), 'Message!');
1232 });
1233
1234 clickCommitButton();
1235
1236 // optimistic state should now be rendered, so we show a fake commit with the new title,
1237 // but not in editing mode anymore
1238
1239 await waitFor(() => {
1240 expectIsNOTEditingTitle();
1241 expectIsNOTEditingDescription();
1242
1243 expect(withinCommitInfo().queryByText('New Commit')).toBeInTheDocument();
1244 expect(withinCommitInfo().queryByText(/Message!/)).toBeInTheDocument();
1245 expect(withinCommitInfo().queryByText('You are here')).toBeInTheDocument();
1246 });
1247
1248 // finish commit operation with hg log
1249 act(() => {
1250 simulateCommits({
1251 value: [
1252 COMMIT('1', 'some public base', '0', {phase: 'public'}),
1253 COMMIT('a', 'My Commit', '1'),
1254 COMMIT('b', 'Head Commit', 'a'),
1255 COMMIT('c', 'New Commit', 'b', {
1256 isDot: true,
1257 description: 'Summary: Message!',
1258 }),
1259 ],
1260 });
1261 });
1262 expect(withinCommitInfo().queryByText('New Commit')).toBeInTheDocument();
1263 expect(withinCommitInfo().getByText(/Message!/)).toBeInTheDocument();
1264 expect(withinCommitInfo().queryByText('You are here')).toBeInTheDocument();
1265 });
1266
1267 it('doesnt let you edit on optimistic commit', async () => {
1268 act(() => {
1269 simulateUncommittedChangedFiles({
1270 value: [{path: 'src/file1.js', status: 'M'}],
1271 });
1272 });
1273
1274 clickCommitMode();
1275 act(() => {
1276 userEvent.type(getTitleEditor(), 'New Commit');
1277 userEvent.type(getDescriptionEditor(), 'Message!');
1278 });
1279 clickCommitButton();
1280
1281 await waitFor(() =>
1282 expectMessageSentToServer({
1283 type: 'runOperation',
1284 operation: expect.objectContaining({
1285 args: expect.arrayContaining(['commit']),
1286 }),
1287 }),
1288 );
1289
1290 clickToEditTitle();
1291 clickToEditDescription();
1292 // cannot click to edit optimistic commit
1293 expectIsNOTEditingTitle();
1294 expectIsNOTEditingDescription();
1295
1296 // finish commit operation with hg log
1297 act(() => {
1298 simulateCommits({
1299 value: [
1300 COMMIT('1', 'some public base', '0', {phase: 'public'}),
1301 COMMIT('a', 'My Commit', '1'),
1302 COMMIT('b', 'Head Commit', 'a'),
1303 COMMIT('c', 'New Commit', 'b', {isDot: true, description: 'Summary: Message!'}),
1304 ],
1305 });
1306 });
1307
1308 act(() => {
1309 clickToEditTitle();
1310 clickToEditDescription();
1311 });
1312 // now you can edit just fine
1313 expectIsEditingTitle();
1314 expectIsEditingDescription();
1315 });
1316
1317 it('renders amend operation smoothly', async () => {
1318 act(() =>
1319 simulateUncommittedChangedFiles({
1320 value: [{path: 'src/file1.js', status: 'M'}],
1321 }),
1322 );
1323
1324 act(() => {
1325 clickToEditTitle();
1326 clickToEditDescription();
1327 });
1328 act(() => {
1329 userEvent.type(getTitleEditor(), ' Hey');
1330 userEvent.type(getDescriptionEditor(), '\nHello');
1331 });
1332
1333 clickAmendButton();
1334
1335 // optimistic state should now be rendered, so we update the head commit
1336 // but not in editing mode anymore
1337
1338 await waitFor(() => {
1339 expectIsNOTEditingTitle();
1340 expectIsNOTEditingDescription();
1341
1342 expect(withinCommitInfo().getByText('Head Commit Hey')).toBeInTheDocument();
1343 expect(
1344 withinCommitInfo().getByText(/stacked commit\nHello/, {
1345 collapseWhitespace: false,
1346 }),
1347 ).toBeInTheDocument();
1348 expect(withinCommitInfo().getByText('You are here')).toBeInTheDocument();
1349 });
1350
1351 // finish amend operation with hg log
1352 act(() => {
1353 simulateCommits({
1354 value: [
1355 COMMIT('1', 'some public base', '0', {phase: 'public'}),
1356 COMMIT('a', 'My Commit', '1'),
1357 COMMIT('b2', 'Head Commit Hey', 'a', {
1358 isDot: true,
1359 description: 'Summary: stacked commit\nHello',
1360 }),
1361 ],
1362 });
1363 });
1364 expect(withinCommitInfo().getByText('Head Commit Hey')).toBeInTheDocument();
1365 expect(
1366 withinCommitInfo().getByText(/stacked commit\nHello/, {
1367 collapseWhitespace: false,
1368 }),
1369 ).toBeInTheDocument();
1370 expect(withinCommitInfo().getByText('You are here')).toBeInTheDocument();
1371 });
1372 });
1373
1374 describe('Opening form in edit mode from uncommitted changes', () => {
1375 beforeEach(() => {
1376 act(() => {
1377 simulateUncommittedChangedFiles({
1378 value: [{path: 'src/file1.js', status: 'M'}],
1379 });
1380 });
1381 });
1382
1383 const clickAmendAs = async () => {
1384 const amendAsButton = screen.getByText('Amend as...');
1385 act(() => {
1386 fireEvent.click(amendAsButton!);
1387 });
1388
1389 await waitFor(() => {
1390 expectIsEditingTitle();
1391 expectIsEditingDescription();
1392 });
1393 };
1394 const clickCommitAs = () => {
1395 const commitAsButton = screen.getByText('Commit as...');
1396 act(() => {
1397 fireEvent.click(commitAsButton!);
1398 });
1399 };
1400
1401 it('Opens form if closed', async () => {
1402 act(() => {
1403 closeCommitInfoSidebar();
1404 });
1405
1406 await clickAmendAs();
1407
1408 expect(screen.getByTestId('commit-info-view')).toBeInTheDocument();
1409 });
1410
1411 it('Deselects so head commit is shown', async () => {
1412 clickToSelectCommit('a');
1413 await clickAmendAs();
1414
1415 await waitFor(() => {
1416 // no commit is selected anymore
1417 expect(screen.queryByTestId('selected-commit')).not.toBeInTheDocument();
1418 expect(withinCommitInfo().queryByText('Head Commit')).toBeInTheDocument();
1419 });
1420 });
1421
1422 describe('Amend as...', () => {
1423 it('Opens form in amend mode', async () => {
1424 clickCommitMode();
1425 await clickAmendAs();
1426
1427 const amendButton: HTMLButtonElement | null = within(
1428 screen.getByTestId('commit-info-actions-bar'),
1429 ).queryByText('Amend');
1430 expect(amendButton).toBeInTheDocument();
1431 });
1432
1433 it('starts editing fields', async () => {
1434 clickCommitMode();
1435 await clickAmendAs();
1436
1437 await waitFor(() => {
1438 expectIsEditingTitle();
1439 expectIsEditingDescription();
1440 });
1441 });
1442
1443 it('focuses fields', async () => {
1444 await clickAmendAs();
1445
1446 await waitFor(() => {
1447 expect(getTitleEditor()).toHaveFocus();
1448 });
1449 });
1450 });
1451
1452 describe('Commit as...', () => {
1453 it('Opens form in commit mode', () => {
1454 clickCommitAs();
1455
1456 const commitButton: HTMLButtonElement | null = within(
1457 screen.getByTestId('commit-info-actions-bar'),
1458 ).queryByText('Commit');
1459 expect(commitButton).toBeInTheDocument();
1460 });
1461
1462 it('focuses fields', async () => {
1463 clickCommitAs();
1464
1465 await waitFor(() => {
1466 expect(getTitleEditor()).toHaveFocus();
1467 });
1468 });
1469
1470 it('focuses fields even if amend fields already being edited', async () => {
1471 await clickAmendAs();
1472
1473 await waitFor(() => {
1474 expect(getTitleEditor()).toHaveFocus();
1475 });
1476 expect(getTitleEditor().value).toEqual('Head Commit');
1477
1478 act(() => {
1479 // explicitly blur title so "commit as" really has to focus it again
1480 getTitleEditor().blur();
1481 });
1482
1483 clickCommitAs();
1484
1485 await waitFor(() => {
1486 expect(getTitleEditor().value).toEqual('');
1487 });
1488 expect(getTitleEditor()).toHaveFocus();
1489 });
1490
1491 it('copies commit title from quick commit form', () => {
1492 const title = screen.getByTestId('quick-commit-title');
1493 act(() => {
1494 userEvent.type(title, 'Hello, world!');
1495 });
1496 clickCommitAs();
1497
1498 expect((screen.getByTestId('quick-commit-title') as HTMLInputElement).value).toEqual(
1499 '',
1500 );
1501 expect(getTitleEditor().value).toBe('Hello, world!');
1502 });
1503 });
1504 });
1505 });
1506
1507 function expectAmendDisabled() {
1508 it('does not allow submitting', () => {
1509 expect(withinCommitInfo().queryByText('Submit')).not.toBeInTheDocument();
1510 });
1511
1512 it('does not show changes to amend', () => {
1513 expect(withinCommitInfo().queryByText('Changes to Amend')).not.toBeInTheDocument();
1514 });
1515
1516 it('does not allow clicking to edit fields', () => {
1517 expectIsNOTEditingTitle();
1518 expectIsNOTEditingDescription();
1519
1520 clickToEditTitle();
1521 clickToEditDescription();
1522
1523 expectIsNOTEditingTitle();
1524 expectIsNOTEditingDescription();
1525 });
1526 }
1527
1528 describe('Public commits in amend mode', () => {
1529 beforeEach(() => {
1530 act(() => {
1531 simulateCommits({
1532 value: [
1533 COMMIT('1', 'some public base', '0', {phase: 'public', isDot: true}),
1534 COMMIT('a', 'My Commit', '1'),
1535 COMMIT('b', 'Head Commit', 'a'),
1536 ],
1537 });
1538 });
1539 });
1540
1541 it('shows public label', () => {
1542 expect(withinCommitInfo().getByText('Public')).toBeInTheDocument();
1543 });
1544
1545 expectAmendDisabled();
1546 });
1547
1548 describe('Obsoleted commits in amend mode', () => {
1549 beforeEach(() => {
1550 act(() => {
1551 simulateCommits({
1552 value: [
1553 COMMIT('1', 'some public base', '0', {phase: 'public'}),
1554 COMMIT('a', 'My Commit V1', '1', {
1555 successorInfo: {hash: 'b', type: 'amend'},
1556 isDot: true,
1557 }),
1558 COMMIT('b', 'Head Commit', '1'),
1559 ],
1560 });
1561 });
1562 });
1563
1564 expectAmendDisabled();
1565 });
1566 });
1567});
1568