| 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 | |
| 8 | import type {ChangedFileStatus, RepoRelativePath, UncommittedChanges} from '../types'; |
| 9 | |
| 10 | import {act, fireEvent, render, screen} from '@testing-library/react'; |
| 11 | import userEvent from '@testing-library/user-event'; |
| 12 | import App from '../App'; |
| 13 | import {defaultChangedFilesDisplayType} from '../ChangedFileDisplayTypePicker'; |
| 14 | import {CommitInfoTestUtils, ignoreRTL} from '../testQueries'; |
| 15 | import { |
| 16 | closeCommitInfoSidebar, |
| 17 | COMMIT, |
| 18 | expectMessageSentToServer, |
| 19 | resetTestMessages, |
| 20 | simulateCommits, |
| 21 | simulateMessageFromServer, |
| 22 | simulateRepoConnected, |
| 23 | simulateUncommittedChangedFiles, |
| 24 | } from '../testUtils'; |
| 25 | import {leftPad} from '../utils'; |
| 26 | |
| 27 | jest.mock('isl-components/OperatingSystem', () => ({ |
| 28 | isMac: true, |
| 29 | })); |
| 30 | |
| 31 | describe('Changed Files', () => { |
| 32 | beforeEach(() => { |
| 33 | resetTestMessages(); |
| 34 | render(<App />); |
| 35 | act(() => { |
| 36 | simulateRepoConnected(); |
| 37 | closeCommitInfoSidebar(); |
| 38 | expectMessageSentToServer({ |
| 39 | type: 'subscribe', |
| 40 | kind: 'smartlogCommits', |
| 41 | subscriptionID: expect.anything(), |
| 42 | }); |
| 43 | simulateCommits({ |
| 44 | value: [ |
| 45 | COMMIT('1', 'some public base', '0', {phase: 'public'}), |
| 46 | COMMIT('a', 'My Commit', '1'), |
| 47 | COMMIT('b', 'Another Commit', 'a', {isDot: true}), |
| 48 | ], |
| 49 | }); |
| 50 | // Reset to the default display type. |
| 51 | act(() => { |
| 52 | simulateMessageFromServer({ |
| 53 | type: 'gotConfig', |
| 54 | name: 'isl.changedFilesDisplayType', |
| 55 | value: JSON.stringify(defaultChangedFilesDisplayType), |
| 56 | }); |
| 57 | }); |
| 58 | expectMessageSentToServer({ |
| 59 | type: 'subscribe', |
| 60 | kind: 'uncommittedChanges', |
| 61 | subscriptionID: expect.anything(), |
| 62 | }); |
| 63 | simulateUncommittedChangedFiles({ |
| 64 | value: [ |
| 65 | {path: 'file1.js', status: 'M'}, |
| 66 | {path: 'src/file2.js', status: 'A'}, |
| 67 | {path: 'src/file3.js', status: 'A'}, |
| 68 | {path: 'src/a/foo.js', status: 'M'}, |
| 69 | {path: 'src/b/foo.js', status: 'M'}, |
| 70 | {path: 'src/subfolder/file4.js', status: 'R'}, |
| 71 | {path: 'src/subfolder/another/yet/another/file5.js', status: 'R'}, |
| 72 | ], |
| 73 | }); |
| 74 | }); |
| 75 | }); |
| 76 | |
| 77 | function openChangedFileStatusPicker() { |
| 78 | const picker = screen.getByTestId('changed-file-display-type-picker'); |
| 79 | expect(picker).toBeInTheDocument(); |
| 80 | |
| 81 | act(() => { |
| 82 | fireEvent.click(picker); |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | it('Allows picking changed files display type', () => { |
| 87 | openChangedFileStatusPicker(); |
| 88 | expect(screen.getByText('Short file names')).toBeInTheDocument(); |
| 89 | expect(screen.getByText('Full file paths')).toBeInTheDocument(); |
| 90 | expect(screen.getByText('Tree')).toBeInTheDocument(); |
| 91 | expect(screen.getByText('One-letter directories')).toBeInTheDocument(); |
| 92 | }); |
| 93 | |
| 94 | it('Persists choice for display type', () => { |
| 95 | openChangedFileStatusPicker(); |
| 96 | act(() => { |
| 97 | fireEvent.click(screen.getByText('Tree')); |
| 98 | }); |
| 99 | expectMessageSentToServer({ |
| 100 | type: 'setConfig', |
| 101 | name: 'isl.changedFilesDisplayType', |
| 102 | value: '"tree"', |
| 103 | }); |
| 104 | }); |
| 105 | |
| 106 | it('Updates when config is fetched', () => { |
| 107 | openChangedFileStatusPicker(); |
| 108 | act(() => { |
| 109 | simulateMessageFromServer({ |
| 110 | type: 'gotConfig', |
| 111 | name: 'isl.changedFilesDisplayType', |
| 112 | value: '"fullPaths"', |
| 113 | }); |
| 114 | }); |
| 115 | expect(screen.getByText(ignoreRTL('src/file2.js'))).toBeInTheDocument(); |
| 116 | }); |
| 117 | |
| 118 | it('Uses LTR markers to render paths correctly', () => { |
| 119 | act(() => { |
| 120 | simulateUncommittedChangedFiles({ |
| 121 | value: [ |
| 122 | {path: '.gitignore', status: 'M'}, |
| 123 | {path: 'src/.gitignore', status: 'A'}, |
| 124 | ], |
| 125 | }); |
| 126 | }); |
| 127 | expect(screen.getByText('\u200E.gitignore\u200E')).toBeInTheDocument(); |
| 128 | expect(screen.getByText('\u200Esrc/.gitignore\u200E')).toBeInTheDocument(); |
| 129 | }); |
| 130 | |
| 131 | describe('default changed files', () => { |
| 132 | it('disambiguates file paths', () => { |
| 133 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 134 | expect(screen.getByText(ignoreRTL('file2.js'))).toBeInTheDocument(); |
| 135 | expect(screen.getByText(ignoreRTL('a/foo.js'))).toBeInTheDocument(); |
| 136 | expect(screen.getByText(ignoreRTL('b/foo.js'))).toBeInTheDocument(); |
| 137 | |
| 138 | expect(screen.queryByText(ignoreRTL('src/file2.js'))).not.toBeInTheDocument(); |
| 139 | }); |
| 140 | }); |
| 141 | |
| 142 | describe('full file paths', () => { |
| 143 | it('shows full paths', () => { |
| 144 | openChangedFileStatusPicker(); |
| 145 | act(() => { |
| 146 | fireEvent.click(screen.getByText('Full file paths')); |
| 147 | }); |
| 148 | |
| 149 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 150 | expect(screen.getByText(ignoreRTL('src/file2.js'))).toBeInTheDocument(); |
| 151 | expect(screen.getByText(ignoreRTL('src/a/foo.js'))).toBeInTheDocument(); |
| 152 | expect(screen.getByText(ignoreRTL('src/b/foo.js'))).toBeInTheDocument(); |
| 153 | expect( |
| 154 | screen.getByText(ignoreRTL('src/subfolder/another/yet/another/file5.js')), |
| 155 | ).toBeInTheDocument(); |
| 156 | }); |
| 157 | |
| 158 | it('shows full paths when holding alt', () => { |
| 159 | expect(screen.queryByText(ignoreRTL('src/b/foo.js'))).not.toBeInTheDocument(); |
| 160 | act(() => { |
| 161 | userEvent.keyboard('{Alt>}'); // '>' means keep pressed |
| 162 | }); |
| 163 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 164 | expect(screen.getByText(ignoreRTL('src/file2.js'))).toBeInTheDocument(); |
| 165 | expect(screen.getByText(ignoreRTL('src/a/foo.js'))).toBeInTheDocument(); |
| 166 | expect(screen.getByText(ignoreRTL('src/b/foo.js'))).toBeInTheDocument(); |
| 167 | expect( |
| 168 | screen.getByText(ignoreRTL('src/subfolder/another/yet/another/file5.js')), |
| 169 | ).toBeInTheDocument(); |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | describe('one-letter-per-directory file paths', () => { |
| 174 | it('shows one-letter-per-directory file paths', () => { |
| 175 | openChangedFileStatusPicker(); |
| 176 | act(() => { |
| 177 | fireEvent.click(screen.getByText('One-letter directories')); |
| 178 | }); |
| 179 | |
| 180 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 181 | expect(screen.getByText(ignoreRTL('s/file2.js'))).toBeInTheDocument(); |
| 182 | expect(screen.getByText(ignoreRTL('s/a/foo.js'))).toBeInTheDocument(); |
| 183 | expect(screen.getByText(ignoreRTL('s/b/foo.js'))).toBeInTheDocument(); |
| 184 | expect(screen.getByText(ignoreRTL('s/s/file4.js'))).toBeInTheDocument(); |
| 185 | expect(screen.getByText(ignoreRTL('s/s/a/y/a/file5.js'))).toBeInTheDocument(); |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | describe('tree', () => { |
| 190 | beforeEach(() => { |
| 191 | openChangedFileStatusPicker(); |
| 192 | act(() => { |
| 193 | fireEvent.click(screen.getByText('Tree')); |
| 194 | }); |
| 195 | }); |
| 196 | |
| 197 | it('shows non-disambiguated file basenames', () => { |
| 198 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 199 | expect(screen.getByText(ignoreRTL('file2.js'))).toBeInTheDocument(); |
| 200 | expect(screen.getByText(ignoreRTL('file3.js'))).toBeInTheDocument(); |
| 201 | expect(screen.getAllByText(ignoreRTL('foo.js'))).toHaveLength(2); |
| 202 | expect(screen.getByText(ignoreRTL('file4.js'))).toBeInTheDocument(); |
| 203 | expect(screen.getByText(ignoreRTL('file5.js'))).toBeInTheDocument(); |
| 204 | }); |
| 205 | |
| 206 | it('shows folder names', () => { |
| 207 | expect(screen.getByText(ignoreRTL('src'))).toBeInTheDocument(); |
| 208 | expect(screen.getByText(ignoreRTL('a'))).toBeInTheDocument(); |
| 209 | expect(screen.getByText(ignoreRTL('b'))).toBeInTheDocument(); |
| 210 | expect(screen.getByText(ignoreRTL('subfolder'))).toBeInTheDocument(); |
| 211 | expect(screen.getByText(ignoreRTL('another/yet/another'))).toBeInTheDocument(); |
| 212 | }); |
| 213 | |
| 214 | it('clicking folder name hides contents', () => { |
| 215 | act(() => { |
| 216 | fireEvent.click(screen.getByText('subfolder')); |
| 217 | }); |
| 218 | expect(screen.queryByText(ignoreRTL('file4.js'))).not.toBeInTheDocument(); |
| 219 | expect(screen.queryByText(ignoreRTL('file5.js'))).not.toBeInTheDocument(); |
| 220 | |
| 221 | expect(screen.getByText(ignoreRTL('file1.js'))).toBeInTheDocument(); |
| 222 | expect(screen.getByText(ignoreRTL('file2.js'))).toBeInTheDocument(); |
| 223 | expect(screen.getByText(ignoreRTL('file3.js'))).toBeInTheDocument(); |
| 224 | }); |
| 225 | |
| 226 | it('clicking folders with the same name do not collapse each other', () => { |
| 227 | act(() => { |
| 228 | simulateUncommittedChangedFiles({ |
| 229 | value: [ |
| 230 | {path: 'a/foo/file1.js', status: 'M'}, |
| 231 | {path: 'a/file2.js', status: 'M'}, |
| 232 | {path: 'b/foo/file3.js', status: 'M'}, |
| 233 | {path: 'b/file4.js', status: 'M'}, |
| 234 | ], |
| 235 | }); |
| 236 | }); |
| 237 | act(() => { |
| 238 | fireEvent.click(screen.getAllByText('foo')[0]); |
| 239 | }); |
| 240 | expect(screen.queryByText(ignoreRTL('file1.js'))).not.toBeInTheDocument(); |
| 241 | expect(screen.queryByText(ignoreRTL('file3.js'))).toBeInTheDocument(); |
| 242 | }); |
| 243 | }); |
| 244 | |
| 245 | describe('truncated list of changed files', () => { |
| 246 | function makeFiles(n: number): Array<RepoRelativePath> { |
| 247 | return new Array(n).fill(null).map((_, i) => `file${leftPad(i, 3, '0')}.txt`); |
| 248 | } |
| 249 | |
| 250 | function withStatus( |
| 251 | changes: Array<RepoRelativePath>, |
| 252 | status: ChangedFileStatus, |
| 253 | ): UncommittedChanges { |
| 254 | return changes.map(path => ({path, status})); |
| 255 | } |
| 256 | |
| 257 | it('only first 500 files are shown', () => { |
| 258 | act(() => { |
| 259 | simulateUncommittedChangedFiles({ |
| 260 | value: withStatus(makeFiles(510), 'M'), |
| 261 | }); |
| 262 | }); |
| 263 | const files = screen.getAllByText(/file\d+\.txt/); |
| 264 | expect(files).toHaveLength(500); |
| 265 | }); |
| 266 | |
| 267 | it('banner is shown if some files are hidden', () => { |
| 268 | act(() => { |
| 269 | simulateUncommittedChangedFiles({ |
| 270 | value: withStatus(makeFiles(700), 'M'), |
| 271 | }); |
| 272 | }); |
| 273 | expect(screen.getByText('Showing first 500 files out of 700 total')).toBeInTheDocument(); |
| 274 | }); |
| 275 | |
| 276 | it('if more than 500 files are provided, there are page navigation buttons', () => { |
| 277 | act(() => { |
| 278 | simulateUncommittedChangedFiles({ |
| 279 | value: withStatus(makeFiles(510), 'M'), |
| 280 | }); |
| 281 | }); |
| 282 | expect(screen.getByTestId('changed-files-next-page')).toBeInTheDocument(); |
| 283 | expect(screen.getByTestId('changed-files-previous-page')).toBeInTheDocument(); |
| 284 | expect(screen.getByTestId('changed-files-previous-page')).toBeDisabled(); |
| 285 | expect(screen.getByText('Showing first 500 files out of 510 total')).toBeInTheDocument(); |
| 286 | }); |
| 287 | |
| 288 | it('can click buttons to navigate pages', () => { |
| 289 | act(() => { |
| 290 | simulateUncommittedChangedFiles({ |
| 291 | value: withStatus(makeFiles(1010), 'M'), |
| 292 | }); |
| 293 | }); |
| 294 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 295 | expect(screen.getByText('Showing files 501 – 1000 out of 1010 total')).toBeInTheDocument(); |
| 296 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 297 | expect(screen.getByText('Showing files 1001 – 1010 out of 1010 total')).toBeInTheDocument(); |
| 298 | |
| 299 | expect(screen.getByTestId('changed-files-next-page')).toBeDisabled(); |
| 300 | |
| 301 | fireEvent.click(screen.getByTestId('changed-files-previous-page')); |
| 302 | expect(screen.getByText('Showing files 501 – 1000 out of 1010 total')).toBeInTheDocument(); |
| 303 | fireEvent.click(screen.getByTestId('changed-files-previous-page')); |
| 304 | expect(screen.getByText('Showing first 500 files out of 1010 total')).toBeInTheDocument(); |
| 305 | }); |
| 306 | |
| 307 | it("if more than 500 files exist, but only 500 are provided, don't show pagination buttons", () => { |
| 308 | act(() => { |
| 309 | simulateUncommittedChangedFiles({ |
| 310 | value: [], |
| 311 | }); |
| 312 | simulateCommits({ |
| 313 | value: [ |
| 314 | COMMIT('1', 'some public base', '0', {phase: 'public'}), |
| 315 | COMMIT('a', 'Commit', '1', { |
| 316 | isDot: true, |
| 317 | filePathsSample: makeFiles(500), |
| 318 | totalFileCount: 1010, |
| 319 | }), |
| 320 | ], |
| 321 | }); |
| 322 | CommitInfoTestUtils.openCommitInfoSidebar(); |
| 323 | }); |
| 324 | |
| 325 | const changedFiles = CommitInfoTestUtils.withinCommitInfo().getByTestId('changed-files'); |
| 326 | expect(changedFiles).toBeInTheDocument(); |
| 327 | |
| 328 | // banner shows truncation |
| 329 | expect( |
| 330 | CommitInfoTestUtils.withinCommitInfo().getByText( |
| 331 | 'Showing first 500 files out of 1010 total', |
| 332 | ), |
| 333 | ).toBeInTheDocument(); |
| 334 | |
| 335 | // but no pagination buttons, since we only provide first 25 anyway |
| 336 | expect( |
| 337 | CommitInfoTestUtils.withinCommitInfo().queryByTestId('changed-files-next-page'), |
| 338 | ).not.toBeInTheDocument(); |
| 339 | expect( |
| 340 | CommitInfoTestUtils.withinCommitInfo().queryByTestId('changed-files-previous-page'), |
| 341 | ).not.toBeInTheDocument(); |
| 342 | }); |
| 343 | |
| 344 | it('if the number of files changes, restrict the page number to fit', () => { |
| 345 | act(() => { |
| 346 | simulateUncommittedChangedFiles({ |
| 347 | value: withStatus(makeFiles(2020), 'M'), |
| 348 | }); |
| 349 | }); |
| 350 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 351 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 352 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 353 | fireEvent.click(screen.getByTestId('changed-files-next-page')); |
| 354 | expect(screen.getByText('Showing files 2001 – 2020 out of 2020 total')).toBeInTheDocument(); |
| 355 | |
| 356 | // now some file changes are removed (e.g. discarded) |
| 357 | act(() => { |
| 358 | simulateUncommittedChangedFiles({ |
| 359 | value: withStatus(makeFiles(700), 'M'), |
| 360 | }); |
| 361 | }); |
| 362 | |
| 363 | // ranges are remapped |
| 364 | expect(screen.getByText('Showing files 501 – 700 out of 700 total')).toBeInTheDocument(); |
| 365 | }); |
| 366 | }); |
| 367 | }); |
| 368 | |