21.3 KB764 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 * as utils from 'shared/utils';
10import App from '../App';
11import {Internal} from '../Internal';
12import {tracker} from '../analytics';
13import {readAtom} from '../jotaiUtils';
14import {operationList} from '../operationsState';
15import {mostRecentSubscriptionIds} from '../serverAPIState';
16import {CommitTreeListTestUtils} from '../testQueries';
17import {
18 closeCommitInfoSidebar,
19 COMMIT,
20 dragAndDropCommits,
21 expectMessageSentToServer,
22 expectYouAreHerePointAt,
23 getLastMessageOfTypeSentToServer,
24 resetTestMessages,
25 simulateCommits,
26 simulateMessageFromServer,
27 simulateRepoConnected,
28 TEST_COMMIT_HISTORY,
29} from '../testUtils';
30
31const {clickGoto} = CommitTreeListTestUtils;
32
33const abortButton = () => screen.queryByTestId('abort-button');
34
35describe('operations', () => {
36 beforeEach(() => {
37 jest.useFakeTimers();
38 resetTestMessages();
39 render(<App />);
40 act(() => {
41 closeCommitInfoSidebar();
42 expectMessageSentToServer({
43 type: 'subscribe',
44 kind: 'smartlogCommits',
45 subscriptionID: expect.anything(),
46 });
47 simulateRepoConnected();
48 simulateCommits({
49 value: TEST_COMMIT_HISTORY,
50 });
51 });
52 });
53
54 afterEach(() => {
55 jest.useRealTimers();
56 });
57
58 it('shows running operation', async () => {
59 await clickGoto('c');
60
61 expect(
62 within(screen.getByTestId('progress-container')).getByText('sl goto --rev c'),
63 ).toBeInTheDocument();
64 });
65
66 it('shows stdout from running command', async () => {
67 await clickGoto('c');
68 const message = await waitFor(() =>
69 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
70 );
71 const id = message.operation.id;
72
73 act(() => {
74 simulateMessageFromServer({
75 type: 'operationProgress',
76 id,
77 kind: 'spawn',
78 queue: [],
79 });
80
81 simulateMessageFromServer({
82 type: 'operationProgress',
83 id,
84 kind: 'stdout',
85 message: 'some progress...',
86 });
87 });
88
89 expect(screen.queryByText('some progress...')).toBeInTheDocument();
90
91 act(() => {
92 simulateMessageFromServer({
93 type: 'operationProgress',
94 id,
95 kind: 'stdout',
96 message: 'another message',
97 });
98 });
99
100 expect(screen.queryByText('another message', {exact: false})).toBeInTheDocument();
101 });
102
103 it('shows stderr from running command', async () => {
104 await clickGoto('c');
105 const message = await waitFor(() =>
106 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
107 );
108 const id = message.operation.id;
109
110 act(() => {
111 simulateMessageFromServer({
112 type: 'operationProgress',
113 id,
114 kind: 'spawn',
115 queue: [],
116 });
117
118 simulateMessageFromServer({
119 type: 'operationProgress',
120 id,
121 kind: 'stderr',
122 message: 'some progress...',
123 });
124 });
125
126 expect(screen.queryByText('some progress...', {exact: false})).toBeInTheDocument();
127
128 act(() => {
129 simulateMessageFromServer({
130 type: 'operationProgress',
131 id,
132 kind: 'stderr',
133 message: 'another message',
134 });
135 });
136
137 expect(screen.queryByText('another message', {exact: false})).toBeInTheDocument();
138 });
139
140 it('shows abort on long-running commands', async () => {
141 await clickGoto('c');
142 expect(abortButton()).toBeNull();
143
144 act(() => {
145 jest.advanceTimersByTime(600000);
146 });
147 expect(abortButton()).toBeInTheDocument();
148 });
149
150 it('shows successful exit status', async () => {
151 await clickGoto('c');
152 const message = await waitFor(() =>
153 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
154 );
155 const id = message.operation.id;
156
157 act(() => {
158 simulateMessageFromServer({
159 type: 'operationProgress',
160 id,
161 kind: 'spawn',
162 queue: [],
163 });
164
165 simulateMessageFromServer({
166 type: 'operationProgress',
167 id,
168 kind: 'exit',
169 exitCode: 0,
170 timestamp: 1234,
171 });
172 });
173
174 expect(screen.queryByLabelText('Command exited successfully')).toBeInTheDocument();
175 expect(
176 within(screen.getByTestId('progress-container')).getByText('sl goto --rev c'),
177 ).toBeInTheDocument();
178 });
179
180 it('shows unsuccessful exit status', async () => {
181 await clickGoto('c');
182 const message = await waitFor(() =>
183 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
184 );
185 const id = message.operation.id;
186
187 act(() => {
188 simulateMessageFromServer({
189 type: 'operationProgress',
190 id,
191 kind: 'spawn',
192 queue: [],
193 });
194
195 simulateMessageFromServer({
196 type: 'operationProgress',
197 id,
198 kind: 'exit',
199 exitCode: -1,
200 timestamp: 1234,
201 });
202 });
203
204 expect(screen.queryByLabelText('Command exited unsuccessfully')).toBeInTheDocument();
205 expect(
206 within(screen.getByTestId('progress-container')).getByText('sl goto --rev c'),
207 ).toBeInTheDocument();
208 });
209
210 it('handles out of order exit messages', async () => {
211 await clickGoto('c');
212 const message1 = await waitFor(() =>
213 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
214 );
215 const id1 = message1.operation.id;
216
217 act(() => {
218 simulateMessageFromServer({
219 type: 'operationProgress',
220 id: id1,
221 kind: 'spawn',
222 queue: [],
223 });
224 });
225
226 await clickGoto('d');
227 const message2 = await waitFor(() =>
228 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
229 );
230 const id2 = message2.operation.id;
231
232 act(() => {
233 simulateMessageFromServer({
234 type: 'operationProgress',
235 id: id2,
236 kind: 'spawn',
237 queue: [],
238 });
239 });
240
241 // get an exit for the SECOND operation before the first
242 act(() => {
243 simulateMessageFromServer({
244 type: 'operationProgress',
245 id: id2,
246 kind: 'exit',
247 exitCode: 0,
248 timestamp: 1234,
249 });
250 });
251
252 // but then get the first
253 act(() => {
254 simulateMessageFromServer({
255 type: 'operationProgress',
256 id: id1,
257 kind: 'exit',
258 exitCode: 0,
259 timestamp: 1234,
260 });
261 });
262
263 // This test is a bit bad: we directly read the jotai state instead of asserting on the UI state.
264 // This is to make sure our state is correct, and isn't represented in the UI in an obvious way.
265 const opList = readAtom(operationList);
266
267 expect(opList.currentOperation).toEqual(
268 expect.objectContaining({
269 operation: expect.objectContaining({id: id2}),
270 exitCode: 0,
271 }),
272 );
273 expect(opList.operationHistory).toEqual([
274 expect.objectContaining({
275 operation: expect.objectContaining({id: id1}),
276 exitCode: 0, // we marked it as exited even though they came out of order
277 }),
278 ]);
279
280 if (Internal.sendAnalyticsDataToServer != null) {
281 expectMessageSentToServer({
282 type: 'track',
283 data: expect.objectContaining({
284 eventName: 'ExitMessageOutOfOrder',
285 }),
286 });
287 }
288 });
289
290 it('reacts to abort', async () => {
291 await clickGoto('c');
292 const message = await waitFor(() =>
293 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
294 );
295 const id = message.operation.id;
296
297 act(() => {
298 jest.advanceTimersByTime(600000);
299 });
300
301 // Start abort
302 fireEvent.click(abortButton() as Element);
303
304 // During abort
305 expect(abortButton()).toBeDisabled();
306
307 // After abort (process exit)
308 act(() => {
309 simulateMessageFromServer({
310 type: 'operationProgress',
311 id,
312 kind: 'exit',
313 exitCode: 130,
314 timestamp: 1234,
315 });
316 });
317 expect(abortButton()).toBeNull();
318 expect(screen.queryByLabelText('Command aborted')).toBeInTheDocument();
319 });
320
321 describe('queued commands', () => {
322 it('optimistically shows queued commands', async () => {
323 await clickGoto('c');
324 const message1 = await waitFor(() =>
325 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
326 );
327 const id1 = message1.operation.id;
328
329 act(() => {
330 simulateMessageFromServer({
331 type: 'operationProgress',
332 id: id1,
333 kind: 'spawn',
334 queue: [],
335 });
336 });
337
338 await clickGoto('a');
339 await clickGoto('b');
340
341 expect(
342 within(screen.getByTestId('queued-commands')).getByText('sl goto --rev a'),
343 ).toBeInTheDocument();
344 expect(
345 within(screen.getByTestId('queued-commands')).getByText('sl goto --rev b'),
346 ).toBeInTheDocument();
347 });
348
349 it('dequeues when the server starts the next command', async () => {
350 await clickGoto('c');
351 const message1 = await waitFor(() =>
352 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
353 );
354 const id1 = message1.operation.id;
355
356 act(() => {
357 simulateMessageFromServer({
358 type: 'operationProgress',
359 id: id1,
360 kind: 'spawn',
361 queue: [],
362 });
363 });
364
365 await clickGoto('a');
366 const message2 = await waitFor(() =>
367 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
368 );
369 const id2 = message2.operation.id;
370
371 expect(
372 within(screen.getByTestId('queued-commands')).getByText('sl goto --rev a'),
373 ).toBeInTheDocument();
374
375 act(() => {
376 simulateMessageFromServer({
377 type: 'operationProgress',
378 id: id2,
379 kind: 'spawn',
380 queue: [],
381 });
382 });
383
384 expect(screen.queryByTestId('queued-commands')).not.toBeInTheDocument();
385 });
386
387 it('takes queued command info from server', async () => {
388 await clickGoto('c');
389 const message1 = await waitFor(() =>
390 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
391 );
392 const id1 = message1.operation.id;
393
394 act(() => {
395 simulateMessageFromServer({
396 type: 'operationProgress',
397 id: id1,
398 kind: 'spawn',
399 queue: [],
400 });
401 });
402
403 await clickGoto('a');
404 const message2 = await waitFor(() =>
405 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
406 );
407 const id2 = message2.operation.id;
408
409 await clickGoto('b');
410 const message3 = await waitFor(() =>
411 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
412 );
413 const id3 = message3.operation.id;
414
415 act(() => {
416 simulateMessageFromServer({
417 type: 'operationProgress',
418 id: id1,
419 kind: 'exit',
420 exitCode: 0,
421 timestamp: 1234,
422 });
423 simulateMessageFromServer({
424 type: 'operationProgress',
425 id: id2,
426 kind: 'spawn',
427 queue: [id3],
428 });
429 });
430
431 expect(
432 within(screen.getByTestId('queued-commands')).getByText('sl goto --rev b'),
433 ).toBeInTheDocument();
434 expect(
435 within(screen.getByTestId('queued-commands')).queryByText('sl goto --rev a'),
436 ).not.toBeInTheDocument();
437 });
438
439 it('error running command cancels queued commands', async () => {
440 await clickGoto('c');
441 const message1 = await waitFor(() =>
442 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
443 );
444 const id1 = message1.operation.id;
445
446 act(() => {
447 simulateMessageFromServer({
448 type: 'operationProgress',
449 id: id1,
450 kind: 'spawn',
451 queue: [],
452 });
453 });
454
455 await clickGoto('a');
456 await clickGoto('b');
457
458 expect(screen.queryByTestId('queued-commands')).toBeInTheDocument();
459 expect(screen.queryByText('Next to run')).toBeInTheDocument();
460 act(() => {
461 // original goto fails
462 simulateMessageFromServer({
463 type: 'operationProgress',
464 id: id1,
465 kind: 'exit',
466 exitCode: -1,
467 timestamp: 1234,
468 });
469 });
470 expect(screen.getByTestId('cancelled-queued-commands')).toBeInTheDocument();
471 expect(screen.queryByText('Next to run')).not.toBeInTheDocument();
472 });
473
474 it('force clears optimistic state after fetching after an operation has finished', async () => {
475 jest.spyOn(tracker, 'track').mockImplementation(() => null);
476 const commitsBeforeOperations = {
477 value: [
478 COMMIT('e', 'Commit E', 'd', {isDot: true}),
479 COMMIT('d', 'Commit D', 'c'),
480 COMMIT('c', 'Commit C', 'b'),
481 COMMIT('b', 'Commit B', 'a'),
482 COMMIT('a', 'Commit A', '1'),
483 COMMIT('1', 'public', '0', {phase: 'public'}),
484 ],
485 };
486 const commitsAfterOperations = {
487 value: [
488 COMMIT('e2', 'Commit E', 'd2'),
489 COMMIT('d2', 'Commit D', 'c2', {isDot: true}), // goto
490 COMMIT('c2', 'Commit C', 'a'), // rebased
491 COMMIT('b', 'Commit B', 'a'),
492 COMMIT('a', 'Commit A', '1'),
493 COMMIT('1', 'public', '0', {phase: 'public'}),
494 ],
495 };
496
497 act(() =>
498 simulateMessageFromServer({
499 type: 'subscriptionResult',
500 kind: 'smartlogCommits',
501 subscriptionID: mostRecentSubscriptionIds.smartlogCommits,
502 data: {
503 fetchStartTimestamp: 1,
504 fetchCompletedTimestamp: 2,
505 commits: commitsBeforeOperations,
506 },
507 }),
508 );
509
510 // 100 200 300 400 500 600 700
511 // |--------|--------|--------|--------|--------|--------|
512 // <----- rebase ---->
513 // ...................<----- goto ----->
514 // <----fetch1---> (no effect)
515 // <---fetch2--> (clears optimistic state)
516
517 // t=100 simulate spawn rebase [c-d-e(YouAreHere)] -> a
518 // t=200 simulate queue goto 'd' (successor: 'd2')
519 // t=300 simulate exit rebase (success)
520 // t=400 simulate spawn goto
521 // t=500 simulate exit goto (success)
522 // no "commitsAfterOperations" state received
523 // expect optimistic "You are here" to be on the old 'e'
524 // t=600 simulate new commits fetch started @ t=450, with new head
525 // no effect
526 // t=700 simulate new commits fetch started @ t=550, with new head
527 // BEFORE: Optimistic state wouldn't resolve, so "You were here..." would stick
528 // AFTER: Optimistic state forced to resolve, so "You were here..." is gone
529
530 dragAndDropCommits('c', 'a');
531 fireEvent.click(screen.getByText('Run Rebase'));
532 await waitFor(() => {
533 expect(screen.getByText('rebasing...')).toBeInTheDocument();
534 });
535
536 // Get the rebase operation ID
537 const rebaseMessage = await waitFor(() =>
538 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
539 );
540 const rebaseId = rebaseMessage.operation.id;
541
542 await clickGoto('d'); // checkout d, which is now optimistic from the rebase, since it'll actually become d2.
543
544 // Get the goto operation ID
545 const gotoMessage = await waitFor(() =>
546 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
547 );
548 const gotoId = gotoMessage.operation.id;
549
550 act(() =>
551 simulateMessageFromServer({
552 type: 'operationProgress',
553 id: rebaseId,
554 kind: 'spawn',
555 queue: [],
556 }),
557 );
558 act(() =>
559 simulateMessageFromServer({
560 type: 'operationProgress',
561 id: gotoId,
562 kind: 'queue',
563 queue: [gotoId],
564 }),
565 );
566 act(() =>
567 simulateMessageFromServer({
568 type: 'operationProgress',
569 id: rebaseId,
570 kind: 'exit',
571 exitCode: 0,
572 timestamp: 300,
573 }),
574 );
575 act(() =>
576 simulateMessageFromServer({
577 type: 'operationProgress',
578 id: gotoId,
579 kind: 'spawn',
580 queue: [],
581 }),
582 );
583 act(() =>
584 simulateMessageFromServer({
585 type: 'operationProgress',
586 id: gotoId,
587 kind: 'exit',
588 exitCode: 0,
589 timestamp: 500,
590 }),
591 );
592 act(() =>
593 simulateMessageFromServer({
594 type: 'operationProgress',
595 id: gotoId,
596 kind: 'exit',
597 exitCode: 0,
598 timestamp: 500,
599 }),
600 );
601
602 act(() =>
603 simulateMessageFromServer({
604 type: 'subscriptionResult',
605 kind: 'smartlogCommits',
606 subscriptionID: mostRecentSubscriptionIds.smartlogCommits,
607 data: {
608 commits: commitsBeforeOperations, // not observed the new head
609 fetchStartTimestamp: 400, // before goto finished
610 fetchCompletedTimestamp: 450,
611 },
612 }),
613 );
614
615 // this latest fetch started before the goto finished, so we don't know that it has all the information
616 // included. So the optimistic state remains (goto 'd').
617 expectYouAreHerePointAt('d');
618
619 act(() =>
620 simulateMessageFromServer({
621 type: 'subscriptionResult',
622 kind: 'smartlogCommits',
623 subscriptionID: mostRecentSubscriptionIds.smartlogCommits,
624 data: {
625 commits: commitsAfterOperations, // observed the new head
626 fetchStartTimestamp: 400, // before goto finished
627 fetchCompletedTimestamp: 450,
628 },
629 }),
630 );
631
632 // However, even if the latest fetch started before the goto finished,
633 // if "goto" saw that head = the new commit, the optimistic state is a
634 // no-op (does not update 'd2' from the smartlog head back to 'd').
635 expectYouAreHerePointAt('d2');
636
637 act(() =>
638 simulateMessageFromServer({
639 type: 'subscriptionResult',
640 kind: 'smartlogCommits',
641 subscriptionID: mostRecentSubscriptionIds.smartlogCommits,
642 data: {
643 commits: commitsBeforeOperations, // intentionally "incorrect" to test the force clear out
644 fetchStartTimestamp: 550, // after goto finished
645 fetchCompletedTimestamp: 600,
646 },
647 }),
648 );
649
650 // This latest fetch started AFTER the goto finished, so we can be sure
651 // it accounts for that operation.
652 // So the optimistic state should be cleared out, even though we didn't
653 // detect that the optimistic state should have resolved according to the applier.
654 // (does not update 'e' from the smartlog head to 'd')
655 expectYouAreHerePointAt('e');
656 });
657 });
658
659 describe('progress messages', () => {
660 it('shows progress messages', async () => {
661 await clickGoto('c');
662 const message = await waitFor(() =>
663 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
664 );
665 const id = message.operation.id;
666
667 act(() => {
668 simulateMessageFromServer({
669 type: 'operationProgress',
670 id,
671 kind: 'spawn',
672 queue: [],
673 });
674
675 simulateMessageFromServer({
676 type: 'operationProgress',
677 id,
678 kind: 'progress',
679 progress: {message: 'doing the thing', progress: 3, progressTotal: 7},
680 });
681 });
682
683 expect(
684 within(screen.getByTestId('progress-container')).getByText('doing the thing'),
685 ).toBeInTheDocument();
686 });
687
688 it('hide progress on new stdout', async () => {
689 await clickGoto('c');
690 const message = await waitFor(() =>
691 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
692 );
693 const id = message.operation.id;
694
695 act(() => {
696 simulateMessageFromServer({
697 type: 'operationProgress',
698 id,
699 kind: 'spawn',
700 queue: [],
701 });
702
703 simulateMessageFromServer({
704 type: 'operationProgress',
705 id,
706 kind: 'progress',
707 progress: {message: 'doing the thing'},
708 });
709 });
710
711 expect(
712 within(screen.getByTestId('progress-container')).getByText('doing the thing'),
713 ).toBeInTheDocument();
714
715 act(() => {
716 simulateMessageFromServer({
717 type: 'operationProgress',
718 id,
719 kind: 'stdout',
720 message: 'hello',
721 });
722 });
723
724 expect(
725 within(screen.getByTestId('progress-container')).queryByText('doing the thing'),
726 ).not.toBeInTheDocument();
727 expect(
728 within(screen.getByTestId('progress-container')).getByText('hello'),
729 ).toBeInTheDocument();
730 });
731 });
732
733 describe('inline progress', () => {
734 it('shows progress messages next to commits', async () => {
735 await clickGoto('c');
736 const message = await waitFor(() =>
737 utils.nullthrows(getLastMessageOfTypeSentToServer('runOperation')),
738 );
739 const id = message.operation.id;
740
741 act(() => {
742 simulateMessageFromServer({
743 type: 'operationProgress',
744 id,
745 kind: 'spawn',
746 queue: [],
747 });
748
749 simulateMessageFromServer({
750 type: 'operationProgress',
751 id,
752 kind: 'inlineProgress',
753 hash: 'c',
754 message: 'going...', // not a real thing for goto operation, but we support arbitrary progress
755 });
756 });
757
758 expect(
759 within(screen.getByTestId('commit-tree-root')).getByText('going...'),
760 ).toBeInTheDocument();
761 });
762 });
763});
764