22.1 KB647 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 type {RenderResult} from '@testing-library/react';
9
10import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
11import fs from 'node:fs';
12import path from 'node:path';
13import {ComparisonType} from 'shared/Comparison';
14import {nextTick} from 'shared/testUtils';
15import {nullthrows} from 'shared/utils';
16import App from '../App';
17import {cancelAllHighlightingTasks} from '../ComparisonView/SplitDiffView/syntaxHighlighting';
18import {parsePatchAndFilter, sortFilesByType} from '../ComparisonView/utils';
19import {
20 COMMIT,
21 expectMessageSentToServer,
22 openCommitInfoSidebar,
23 resetTestMessages,
24 simulateCommits,
25 simulateMessageFromServer,
26 simulateUncommittedChangedFiles,
27 waitForWithTick,
28} from '../testUtils';
29import {GeneratedStatus} from '../types';
30
31afterEach(cleanup);
32
33const UNCOMMITTED_CHANGES_DIFF = `\
34diff --git deletedFile.txt deletedFile.txt
35deleted file mode 100644
36--- deletedFile.txt
37+++ /dev/null
38@@ -1,1 +0,0 @@
39-Goodbye
40diff --git newFile.txt newFile.txt
41new file mode 100644
42--- /dev/null
43+++ newFile.txt
44@@ -0,0 +1,1 @@
45+hello
46diff --git someFile.txt someFile.txt
47--- someFile.txt
48+++ someFile.txt
49@@ -7,5 +7,5 @@
50 line 7
51 line 8
52-line 9
53+line 9 - modified
54 line 10
55 line 11
56diff --git -r a1b2c3d4e5f6 some/path/foo.go
57--- some/path/foo.go
58+++ some/path/foo.go
59@@ -0,1 +0,1 @@
60-println("hi")
61+fmt.Println("hi")
62`;
63
64const DIFF_WITH_SYNTAX = `\
65diff --git deletedFile.js deletedFile.js
66deleted file mode 100644
67--- deletedFile.js
68+++ /dev/null
69@@ -1,1 +0,0 @@
70-console.log('goodbye');
71diff --git newFile.js newFile.js
72new file mode 100644
73--- /dev/null
74+++ newFile.js
75@@ -0,0 +1,1 @@
76+console.log('hello');
77diff --git someFile.js someFile.js
78--- someFile.js
79+++ someFile.js
80@@ -2,5 +2,5 @@
81 function foo() {
82 const variable_in_context_line = 0;
83- const variable_in_before = 1;
84+ const variable_in_after = 1;
85 console.log(variable_in_content_line);
86 }
87`;
88
89Object.defineProperty(navigator, 'clipboard', {
90 value: {
91 writeText: jest.fn(() => Promise.resolve()),
92 },
93});
94
95/* eslint-disable @typescript-eslint/no-non-null-assertion */
96
97describe('ComparisonView', () => {
98 let app: RenderResult | null = null;
99 beforeEach(() => {
100 mockFetchToSupportSyntaxHighlighting();
101 resetTestMessages();
102 app = render(<App />);
103 act(() => {
104 openCommitInfoSidebar();
105 simulateCommits({
106 value: [
107 COMMIT('1', 'some public base', '0', {phase: 'public'}),
108 COMMIT('a', 'My Commit', '1'),
109 COMMIT('b', 'Another Commit', 'a', {isDot: true}),
110 ],
111 });
112 simulateUncommittedChangedFiles({
113 value: [{path: 'src/file1.txt', status: 'M'}],
114 });
115 });
116 });
117
118 // Not afterEach because afterEach runs in a separate tick and can be too
119 // late to avoid act(..) warnings or timeout.
120 function unmountNow() {
121 cancelAllHighlightingTasks();
122 app?.unmount();
123 }
124
125 afterEach(() => {
126 jest.clearAllMocks();
127 });
128
129 async function clickComparisonViewButton() {
130 await act(async () => {
131 const button = screen.getByTestId('open-comparison-view-button-Uncommitted');
132 fireEvent.click(button);
133 await nextTick();
134 });
135 }
136 async function openUncommittedChangesComparison(
137 diffContent?: string,
138 generatedStatuses?: Record<string, GeneratedStatus>,
139 ) {
140 await clickComparisonViewButton();
141 await waitFor(
142 () =>
143 expectMessageSentToServer({
144 type: 'requestComparison',
145 comparison: {type: ComparisonType.UncommittedChanges},
146 }),
147 // Since this dynamically imports the comparison view, it may take a while to load in resource-constrained CI,
148 // so add a generous timeout to reducy flakiness.
149 {timeout: 10_000},
150 );
151 act(() => {
152 simulateMessageFromServer({
153 type: 'fetchedGeneratedStatuses',
154 results: generatedStatuses ?? {},
155 });
156 });
157 await act(async () => {
158 simulateMessageFromServer({
159 type: 'comparison',
160 comparison: {type: ComparisonType.UncommittedChanges},
161 data: {diff: {value: diffContent ?? UNCOMMITTED_CHANGES_DIFF}},
162 });
163 await nextTick();
164 });
165 }
166 function inComparisonView() {
167 return within(screen.getByTestId('comparison-view'));
168 }
169
170 function closeComparisonView() {
171 const closeButton = inComparisonView().getByTestId('close-comparison-view-button');
172 expect(closeButton).toBeInTheDocument();
173 act(() => {
174 fireEvent.click(closeButton);
175 });
176 }
177
178 it('Loads comparison', async () => {
179 await openUncommittedChangesComparison();
180 // Prevent act(..) warnings. This cannot be afterEach() which is too late.
181 unmountNow();
182 });
183
184 it('parses files from comparison', async () => {
185 await openUncommittedChangesComparison();
186 expect(inComparisonView().getByText('someFile.txt')).toBeInTheDocument();
187 expect(inComparisonView().getByText('newFile.txt')).toBeInTheDocument();
188 expect(inComparisonView().getByText('deletedFile.txt')).toBeInTheDocument();
189 unmountNow();
190 });
191
192 it('show file contents', async () => {
193 await openUncommittedChangesComparison();
194 expect(inComparisonView().getByText('- modified')).toBeInTheDocument();
195 expect(inComparisonView().getAllByText('line 7')[0]).toBeInTheDocument();
196 expect(inComparisonView().getAllByText('line 8')[0]).toBeInTheDocument();
197 expect(inComparisonView().getAllByText('line 9')[0]).toBeInTheDocument();
198 expect(inComparisonView().getAllByText('line 10')[0]).toBeInTheDocument();
199 expect(inComparisonView().getAllByText('line 11')[0]).toBeInTheDocument();
200 unmountNow();
201 });
202
203 it('loads remaining lines', async () => {
204 await openUncommittedChangesComparison();
205 const expandButton = inComparisonView().getByText('Expand 6 lines');
206 expect(expandButton).toBeInTheDocument();
207 act(() => {
208 fireEvent.click(expandButton);
209 });
210 await waitFor(() => {
211 expectMessageSentToServer({
212 type: 'requestComparisonContextLines',
213 id: {path: 'someFile.txt', comparison: {type: ComparisonType.UncommittedChanges}},
214 numLines: 6,
215 start: 1,
216 });
217 });
218 act(() => {
219 simulateMessageFromServer({
220 type: 'comparisonContextLines',
221 lines: {value: ['line 1', 'line 2', 'line 3', 'line 4', 'line 5', 'line 6']},
222 path: 'someFile.txt',
223 });
224 });
225 await waitFor(() => {
226 expect(inComparisonView().getAllByText('line 1')[0]).toBeInTheDocument();
227 expect(inComparisonView().getAllByText('line 2')[0]).toBeInTheDocument();
228 expect(inComparisonView().getAllByText('line 3')[0]).toBeInTheDocument();
229 expect(inComparisonView().getAllByText('line 4')[0]).toBeInTheDocument();
230 expect(inComparisonView().getAllByText('line 5')[0]).toBeInTheDocument();
231 expect(inComparisonView().getAllByText('line 6')[0]).toBeInTheDocument();
232 });
233 unmountNow();
234 });
235
236 it('can close comparison', async () => {
237 await openUncommittedChangesComparison();
238 expect(inComparisonView().getByText('- modified')).toBeInTheDocument();
239 closeComparisonView();
240 expect(screen.queryByText('- modified')).not.toBeInTheDocument();
241 unmountNow();
242 });
243
244 it('invalidates cached remaining lines when the head commit changes', async () => {
245 await openUncommittedChangesComparison();
246 const clickExpand = () => {
247 const expandButton = inComparisonView().getByText('Expand 6 lines');
248 expect(expandButton).toBeInTheDocument();
249 act(() => {
250 fireEvent.click(expandButton);
251 });
252 };
253 clickExpand();
254 await waitFor(() => {
255 expectMessageSentToServer({
256 type: 'requestComparisonContextLines',
257 id: {path: 'someFile.txt', comparison: {type: ComparisonType.UncommittedChanges}},
258 numLines: 6,
259 start: 1,
260 });
261 });
262 act(() => {
263 simulateMessageFromServer({
264 type: 'comparisonContextLines',
265 lines: {value: ['line 1', 'line 2', 'line 3', 'line 4', 'line 5', 'line 6']},
266 path: 'someFile.txt',
267 });
268 });
269 await waitForWithTick(() => {
270 expect(inComparisonView().getAllByText('line 1')[0]).toBeInTheDocument();
271 expect(inComparisonView().getAllByText('line 6')[0]).toBeInTheDocument();
272 });
273
274 closeComparisonView();
275 resetTestMessages(); // make sure we don't find previous "requestComparisonContextLines" in later assertions
276
277 // head commit changes
278
279 act(() => {
280 simulateCommits({
281 value: [
282 COMMIT('1', 'some public base', '0', {phase: 'public'}),
283 COMMIT('a', 'My Commit', '1'),
284 COMMIT('b', 'Another Commit', 'a'),
285 COMMIT('c', 'New commit!', 'b', {isDot: true}),
286 ],
287 });
288 });
289 await openUncommittedChangesComparison();
290 expect(inComparisonView().getByText('- modified')).toBeInTheDocument();
291
292 clickExpand();
293
294 // previous context lines are no longer there
295 expect(inComparisonView().queryByText('line 1')).not.toBeInTheDocument();
296
297 // it should ask for the line contents from the server again
298 await waitFor(() => {
299 expectMessageSentToServer({
300 type: 'requestComparisonContextLines',
301 id: {path: 'someFile.txt', comparison: {type: ComparisonType.UncommittedChanges}},
302 numLines: 6,
303 start: 1,
304 });
305 });
306 act(() => {
307 simulateMessageFromServer({
308 type: 'comparisonContextLines',
309 lines: {
310 value: [
311 'different line 1',
312 'different line 2',
313 'different line 3',
314 'different line 4',
315 'different line 5',
316 'different line 6',
317 ],
318 },
319 path: 'someFile.txt',
320 });
321 });
322 // new data is used
323 await waitForWithTick(() => {
324 expect(inComparisonView().getAllByText('different line 1')[0]).toBeInTheDocument();
325 expect(inComparisonView().getAllByText('different line 6')[0]).toBeInTheDocument();
326 });
327 unmountNow();
328 });
329
330 it('refresh button requests new data', async () => {
331 await openUncommittedChangesComparison();
332 resetTestMessages();
333
334 act(() => {
335 fireEvent.click(inComparisonView().getByTestId('comparison-refresh-button'));
336 });
337
338 expectMessageSentToServer({
339 type: 'requestComparison',
340 comparison: {type: ComparisonType.UncommittedChanges},
341 });
342 unmountNow();
343 });
344
345 it('changing comparison mode requests new data', async () => {
346 await openUncommittedChangesComparison();
347
348 act(() => {
349 fireEvent.change(inComparisonView().getByTestId('comparison-view-picker'), {
350 target: {value: ComparisonType.StackChanges},
351 });
352 });
353 expectMessageSentToServer({
354 type: 'requestComparison',
355 comparison: {type: ComparisonType.StackChanges},
356 });
357 unmountNow();
358 });
359
360 it('shows a spinner while a fetch is ongoing', async () => {
361 await clickComparisonViewButton();
362 expect(inComparisonView().getByTestId('comparison-loading')).toBeInTheDocument();
363
364 await act(async () => {
365 simulateMessageFromServer({
366 type: 'comparison',
367 comparison: {type: ComparisonType.UncommittedChanges},
368 data: {diff: {value: UNCOMMITTED_CHANGES_DIFF}},
369 });
370 await nextTick();
371 });
372 expect(inComparisonView().queryByTestId('comparison-loading')).not.toBeInTheDocument();
373 unmountNow();
374 });
375
376 it('copies file path on click', async () => {
377 await openUncommittedChangesComparison();
378
379 // Click on the "foo.go" of "some/path/foo.go".
380 act(() => {
381 fireEvent.click(inComparisonView().getByText('foo.go'));
382 });
383 expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
384 expect(navigator.clipboard.writeText).toHaveBeenCalledWith('foo.go');
385
386 // Click on the "some/" of "some/path/foo.go".
387 act(() => {
388 fireEvent.click(inComparisonView().getByText('some/'));
389 });
390 expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(2);
391 expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith('some/path/foo.go');
392 unmountNow();
393 });
394
395 describe('syntax highlighting', () => {
396 it('renders syntax highlighting', async () => {
397 await openUncommittedChangesComparison(DIFF_WITH_SYNTAX);
398 await waitForSyntaxHighlightingToAppear(screen.getByTestId('comparison-view'));
399
400 // console from console.log is highlighted as its own token
401 const tokens = within(screen.getByTestId('comparison-view')).queryAllByText('console');
402 expect(tokens.length).toBeGreaterThan(0);
403 // highlighted tokens have classes like mtk1, mtk2, etc.
404 expect(tokens.some(token => /mtk\d+/.test(token.className))).toBe(true);
405 unmountNow();
406 });
407
408 it('renders highlighting in context lines and diff lines', async () => {
409 await openUncommittedChangesComparison(DIFF_WITH_SYNTAX);
410 await waitForSyntaxHighlightingToAppear(screen.getByTestId('comparison-view'));
411
412 await waitFor(() => {
413 expect(
414 within(screen.getByTestId('comparison-view')).queryAllByText('variable_in_context_line'),
415 ).toHaveLength(2);
416 expect(
417 within(screen.getByTestId('comparison-view')).getByText('variable_in_before'),
418 ).toBeInTheDocument();
419 expect(
420 within(screen.getByTestId('comparison-view')).getByText('variable_in_after'),
421 ).toBeInTheDocument();
422 });
423 unmountNow();
424 });
425
426 it('highlights expanded context lines', async () => {
427 await openUncommittedChangesComparison(DIFF_WITH_SYNTAX);
428 await waitForSyntaxHighlightingToAppear(screen.getByTestId('comparison-view'));
429
430 const expandButton = inComparisonView().getByText('Expand 1 line');
431 expect(expandButton).toBeInTheDocument();
432 act(() => {
433 fireEvent.click(expandButton);
434 });
435 await waitFor(() => {
436 expectMessageSentToServer({
437 type: 'requestComparisonContextLines',
438 id: {path: 'someFile.js', comparison: {type: ComparisonType.UncommittedChanges}},
439 numLines: 1,
440 start: 1,
441 });
442 });
443 act(() => {
444 simulateMessageFromServer({
445 type: 'comparisonContextLines',
446 lines: {value: ['const loaded_additional_context_variable = 5;']},
447 path: 'someFile.js',
448 });
449 });
450 await waitFor(() => {
451 // highlighted token appears by itself
452 expect(
453 inComparisonView().queryAllByText('loaded_additional_context_variable'),
454 ).toHaveLength(2);
455 });
456 unmountNow();
457 });
458 });
459
460 const makeFileDiff = (name: string, content: string) => {
461 return `diff --git file${name}.txt file${name}.txt
462--- file${name}.txt
463+++ file${name}.txt
464@@ -1,2 +1,2 @@
465${content}
466`;
467 };
468
469 describe('collapsing files', () => {
470 it('can click to collapse files', async () => {
471 const SINGLE_CHANGE = makeFileDiff('1', '+const x = 1;');
472 await openUncommittedChangesComparison(SINGLE_CHANGE);
473
474 const collapseButton = screen.getByTestId('split-diff-view-file-header-collapse-button');
475 expect(inComparisonView().getByText('const x = 1;')).toBeInTheDocument();
476 expect(inComparisonView().getByText('file1.txt')).toBeInTheDocument();
477 fireEvent.click(collapseButton);
478 expect(inComparisonView().queryByText('const x = 1;')).not.toBeInTheDocument();
479 expect(inComparisonView().getByText('file1.txt')).toBeInTheDocument();
480 });
481
482 it('first files are expanded, later files are collapsed', async () => {
483 // 10 files, 1000 added lines each
484 const HUGE_DIFF = [
485 ...new Array(10)
486 .fill(undefined)
487 .map((_, index) =>
488 makeFileDiff(String(index), new Array(1001).fill("+console.log('hi');").join('\n')),
489 ),
490 ].join('\n');
491 await openUncommittedChangesComparison(HUGE_DIFF);
492
493 const collapsedStates = inComparisonView().queryAllByTestId(
494 /split-diff-view-file-header-(collapse|expand)-button/,
495 );
496 const collapsedValues = collapsedStates.map(node => node.dataset.testid);
497 expect(collapsedValues).toEqual([
498 'split-diff-view-file-header-collapse-button',
499 'split-diff-view-file-header-collapse-button',
500 'split-diff-view-file-header-collapse-button',
501 'split-diff-view-file-header-expand-button',
502 'split-diff-view-file-header-expand-button',
503 'split-diff-view-file-header-expand-button',
504 'split-diff-view-file-header-expand-button',
505 'split-diff-view-file-header-expand-button',
506 'split-diff-view-file-header-expand-button',
507 'split-diff-view-file-header-expand-button',
508 ]);
509 unmountNow();
510 }, 20_000 /* potentially slow test */);
511
512 it('a single large file is expanded so you always see something', async () => {
513 const GIANT_FILE = makeFileDiff(
514 'bigChange.txt',
515 new Array(5000).fill('+big_file_contents').join('\n'),
516 );
517 const SMALL_FILE = makeFileDiff('smallChange.txt', '+small_file_contents');
518 const GIANT_AND_SMALL = [GIANT_FILE, SMALL_FILE].join('\n');
519 await openUncommittedChangesComparison(GIANT_AND_SMALL);
520
521 // the large file starts expanded
522 expect(inComparisonView().getAllByText('big_file_contents').length).toBeGreaterThan(0);
523 // the small file starts collapsed
524 expect(inComparisonView().queryByText('small_file_contents')).not.toBeInTheDocument();
525 unmountNow();
526 });
527 });
528
529 describe('generated files', () => {
530 it('generated status is fetched for files being compared', async () => {
531 const NORMAL_FILE = makeFileDiff('normal1', '+normal_contents');
532 const PARTIAL_FILE = makeFileDiff('partial1', '+partial_contents');
533 const GENERATED_FILE = makeFileDiff('generated1', '+generated_contents');
534 const ALL = [GENERATED_FILE, PARTIAL_FILE, NORMAL_FILE].join('\n');
535
536 await openUncommittedChangesComparison(ALL);
537 await waitFor(() => {
538 expectMessageSentToServer({
539 type: 'fetchGeneratedStatuses',
540 paths: ['filegenerated1.txt', 'filepartial1.txt', 'filenormal1.txt'],
541 });
542 });
543 unmountNow();
544 });
545
546 it('banner says that files are generated', async () => {
547 const NORMAL_FILE = makeFileDiff('normal2', '+normal_contents');
548 const PARTIAL_FILE = makeFileDiff('partial2', '+partial_contents');
549 const GENERATED_FILE = makeFileDiff('generated2', '+generated_contents');
550 const ALL = [GENERATED_FILE, PARTIAL_FILE, NORMAL_FILE].join('\n');
551
552 await openUncommittedChangesComparison(ALL, {
553 'filenormal2.txt': GeneratedStatus.Manual,
554 'filegenerated2.txt': GeneratedStatus.Generated,
555 'filepartial2.txt': GeneratedStatus.PartiallyGenerated,
556 });
557 expect(inComparisonView().getByText('This file is generated')).toBeInTheDocument();
558 expect(inComparisonView().getByText('This file is partially generated')).toBeInTheDocument();
559 unmountNow();
560 });
561
562 it('generated files are collapsed by default', async () => {
563 const NORMAL_FILE = makeFileDiff('normal3', '+normal_contents');
564 const PARTIAL_FILE = makeFileDiff('partial3', '+partial_contents');
565 const GENERATED_FILE = makeFileDiff('generated3', '+generated_contents');
566 const ALL = [GENERATED_FILE, PARTIAL_FILE, NORMAL_FILE].join('\n');
567
568 await openUncommittedChangesComparison(ALL, {
569 'filenormal3.txt': GeneratedStatus.Manual,
570 'filegenerated3.txt': GeneratedStatus.Generated,
571 'filepartial3.txt': GeneratedStatus.PartiallyGenerated,
572 });
573
574 // normal, partial start expanded
575 expect(inComparisonView().getByText('normal_contents')).toBeInTheDocument();
576 expect(inComparisonView().getByText('partial_contents')).toBeInTheDocument();
577 await waitFor(() => {
578 // generated starts collapsed
579 expect(inComparisonView().queryByText('generated_contents')).not.toBeInTheDocument();
580 });
581
582 expect(inComparisonView().getByText('Show anyway')).toBeInTheDocument();
583 fireEvent.click(inComparisonView().getByText('Show anyway'));
584 await waitFor(() => {
585 // generated now expands
586 expect(inComparisonView().getByText('generated_contents')).toBeInTheDocument();
587 });
588 unmountNow();
589 });
590 });
591});
592
593function waitForSyntaxHighlightingToAppear(inside: HTMLElement): Promise<void> {
594 return waitFor(() => {
595 const tokens = inside.querySelectorAll('.mtk1');
596 expect(tokens.length).toBeGreaterThan(0);
597 });
598}
599
600function mockFetchToSupportSyntaxHighlighting(): jest.SpyInstance {
601 return jest.spyOn(global, 'fetch').mockImplementation(
602 jest.fn(async (url: URL) => {
603 if (url.pathname.includes('generated/textmate')) {
604 const match = /.*generated\/textmate\/(.*)$/.exec(url.pathname);
605 const filename = nullthrows(match)[1];
606 const toPublicDir = (filename: string) =>
607 path.normalize(path.join(__dirname, '../../public/generated/textmate', filename));
608 if (filename === 'onig.wasm') {
609 const file = await fs.promises.readFile(toPublicDir(filename));
610 return {
611 headers: new Map(),
612 arrayBuffer: () => file.buffer,
613 };
614 } else {
615 const file = await fs.promises.readFile(toPublicDir(filename), 'utf-8');
616 return {text: () => file};
617 }
618 }
619 throw new Error(`${url} not found`);
620 }) as jest.Mock,
621 );
622}
623
624describe('ComparisonView utils', () => {
625 describe('sortFilesByType', () => {
626 it('sorts by type', () => {
627 const files = parsePatchAndFilter(UNCOMMITTED_CHANGES_DIFF);
628
629 expect(files).toEqual([
630 expect.objectContaining({newFileName: 'deletedFile.txt', type: 'Removed'}),
631 expect.objectContaining({newFileName: 'newFile.txt', type: 'Added'}),
632 expect.objectContaining({newFileName: 'someFile.txt', type: 'Modified'}),
633 expect.objectContaining({newFileName: 'some/path/foo.go', type: 'Modified'}),
634 ]);
635
636 sortFilesByType(files);
637
638 expect(files).toEqual([
639 expect.objectContaining({newFileName: 'some/path/foo.go', type: 'Modified'}),
640 expect.objectContaining({newFileName: 'someFile.txt', type: 'Modified'}),
641 expect.objectContaining({newFileName: 'newFile.txt', type: 'Added'}),
642 expect.objectContaining({newFileName: 'deletedFile.txt', type: 'Removed'}),
643 ]);
644 });
645 });
646});
647